index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useEffect, useState } from 'react'
  4. import { ChevronRightIcon } from '@heroicons/react/20/solid'
  5. import Link from 'next/link'
  6. import { Trans, useTranslation } from 'react-i18next'
  7. import { useContextSelector } from 'use-context-selector'
  8. import s from './style.module.css'
  9. import Modal from '@/app/components/base/modal'
  10. import Button from '@/app/components/base/button'
  11. import AppIcon from '@/app/components/base/app-icon'
  12. import Switch from '@/app/components/base/switch'
  13. import { SimpleSelect } from '@/app/components/base/select'
  14. import type { AppDetailResponse } from '@/models/app'
  15. import type { AppIconType, AppSSO, Language } from '@/types/app'
  16. import { useToastContext } from '@/app/components/base/toast'
  17. import { languages } from '@/i18n/language'
  18. import Tooltip from '@/app/components/base/tooltip'
  19. import AppContext, { useAppContext } from '@/context/app-context'
  20. import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
  21. import AppIconPicker from '@/app/components/base/app-icon-picker'
  22. export type ISettingsModalProps = {
  23. isChat: boolean
  24. appInfo: AppDetailResponse & Partial<AppSSO>
  25. isShow: boolean
  26. defaultValue?: string
  27. onClose: () => void
  28. onSave?: (params: ConfigParams) => Promise<void>
  29. }
  30. export type ConfigParams = {
  31. title: string
  32. description: string
  33. default_language: string
  34. chat_color_theme: string
  35. chat_color_theme_inverted: boolean
  36. prompt_public: boolean
  37. copyright: string
  38. privacy_policy: string
  39. custom_disclaimer: string
  40. icon_type: AppIconType
  41. icon: string
  42. icon_background?: string
  43. show_workflow_steps: boolean
  44. use_icon_as_answer_icon: boolean
  45. enable_sso?: boolean
  46. }
  47. const prefixSettings = 'appOverview.overview.appInfo.settings'
  48. const SettingsModal: FC<ISettingsModalProps> = ({
  49. isChat,
  50. appInfo,
  51. isShow = false,
  52. onClose,
  53. onSave,
  54. }) => {
  55. const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
  56. const { isCurrentWorkspaceEditor } = useAppContext()
  57. const { notify } = useToastContext()
  58. const [isShowMore, setIsShowMore] = useState(false)
  59. const {
  60. title,
  61. icon_type,
  62. icon,
  63. icon_background,
  64. icon_url,
  65. description,
  66. chat_color_theme,
  67. chat_color_theme_inverted,
  68. copyright,
  69. privacy_policy,
  70. custom_disclaimer,
  71. default_language,
  72. show_workflow_steps,
  73. use_icon_as_answer_icon,
  74. } = appInfo.site
  75. const [inputInfo, setInputInfo] = useState({
  76. title,
  77. desc: description,
  78. chatColorTheme: chat_color_theme,
  79. chatColorThemeInverted: chat_color_theme_inverted,
  80. copyright,
  81. privacyPolicy: privacy_policy,
  82. customDisclaimer: custom_disclaimer,
  83. show_workflow_steps,
  84. use_icon_as_answer_icon,
  85. enable_sso: appInfo.enable_sso,
  86. })
  87. const [language, setLanguage] = useState(default_language)
  88. const [saveLoading, setSaveLoading] = useState(false)
  89. const { t } = useTranslation()
  90. const [showAppIconPicker, setShowAppIconPicker] = useState(false)
  91. const [appIcon, setAppIcon] = useState<AppIconSelection>(
  92. icon_type === 'image'
  93. ? { type: 'image', url: icon_url!, fileId: icon }
  94. : { type: 'emoji', icon, background: icon_background! },
  95. )
  96. const isChatBot = appInfo.mode === 'chat' || appInfo.mode === 'advanced-chat' || appInfo.mode === 'agent-chat'
  97. useEffect(() => {
  98. setInputInfo({
  99. title,
  100. desc: description,
  101. chatColorTheme: chat_color_theme,
  102. chatColorThemeInverted: chat_color_theme_inverted,
  103. copyright,
  104. privacyPolicy: privacy_policy,
  105. customDisclaimer: custom_disclaimer,
  106. show_workflow_steps,
  107. use_icon_as_answer_icon,
  108. enable_sso: appInfo.enable_sso,
  109. })
  110. setLanguage(default_language)
  111. setAppIcon(icon_type === 'image'
  112. ? { type: 'image', url: icon_url!, fileId: icon }
  113. : { type: 'emoji', icon, background: icon_background! })
  114. }, [appInfo])
  115. const onHide = () => {
  116. onClose()
  117. setTimeout(() => {
  118. setIsShowMore(false)
  119. }, 200)
  120. }
  121. const onClickSave = async () => {
  122. if (!inputInfo.title) {
  123. notify({ type: 'error', message: t('app.newApp.nameNotEmpty') })
  124. return
  125. }
  126. const validateColorHex = (hex: string | null) => {
  127. if (hex === null || hex?.length === 0)
  128. return true
  129. const regex = /#([A-Fa-f0-9]{6})/
  130. const check = regex.test(hex)
  131. return check
  132. }
  133. if (inputInfo !== null) {
  134. if (!validateColorHex(inputInfo.chatColorTheme)) {
  135. notify({ type: 'error', message: t(`${prefixSettings}.invalidHexMessage`) })
  136. return
  137. }
  138. }
  139. setSaveLoading(true)
  140. const params = {
  141. title: inputInfo.title,
  142. description: inputInfo.desc,
  143. default_language: language,
  144. chat_color_theme: inputInfo.chatColorTheme,
  145. chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
  146. prompt_public: false,
  147. copyright: inputInfo.copyright,
  148. privacy_policy: inputInfo.privacyPolicy,
  149. custom_disclaimer: inputInfo.customDisclaimer,
  150. icon_type: appIcon.type,
  151. icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
  152. icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
  153. show_workflow_steps: inputInfo.show_workflow_steps,
  154. use_icon_as_answer_icon: inputInfo.use_icon_as_answer_icon,
  155. enable_sso: inputInfo.enable_sso,
  156. }
  157. await onSave?.(params)
  158. setSaveLoading(false)
  159. onHide()
  160. }
  161. const onChange = (field: string) => {
  162. return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
  163. let value: string | boolean
  164. if (e.target.type === 'checkbox')
  165. value = (e.target as HTMLInputElement).checked
  166. else
  167. value = e.target.value
  168. setInputInfo(item => ({ ...item, [field]: value }))
  169. }
  170. }
  171. return (
  172. <>
  173. <Modal
  174. title={t(`${prefixSettings}.title`)}
  175. isShow={isShow}
  176. onClose={onHide}
  177. className={`${s.settingsModal}`}
  178. >
  179. <div className={`mt-6 font-medium ${s.settingTitle} text-gray-900`}>{t(`${prefixSettings}.webName`)}</div>
  180. <div className='flex mt-2'>
  181. <AppIcon size='large'
  182. onClick={() => { setShowAppIconPicker(true) }}
  183. className='cursor-pointer !mr-3 self-center'
  184. iconType={appIcon.type}
  185. icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
  186. background={appIcon.type === 'image' ? undefined : appIcon.background}
  187. imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
  188. />
  189. <input className={`flex-grow rounded-lg h-10 box-border px-3 ${s.projectName} bg-gray-100`}
  190. value={inputInfo.title}
  191. onChange={onChange('title')}
  192. placeholder={t('app.appNamePlaceholder') || ''}
  193. />
  194. </div>
  195. <div className={`mt-6 font-medium ${s.settingTitle} text-gray-900 `}>{t(`${prefixSettings}.webDesc`)}</div>
  196. <p className={`mt-1 ${s.settingsTip} text-gray-500`}>{t(`${prefixSettings}.webDescTip`)}</p>
  197. <textarea
  198. rows={3}
  199. className={`mt-2 pt-2 pb-2 px-3 rounded-lg bg-gray-100 w-full ${s.settingsTip} text-gray-900`}
  200. value={inputInfo.desc}
  201. onChange={onChange('desc')}
  202. placeholder={t(`${prefixSettings}.webDescPlaceholder`) as string}
  203. />
  204. {isChatBot && (
  205. <div className='w-full mt-4'>
  206. <div className='flex justify-between items-center'>
  207. <div className={`font-medium ${s.settingTitle} text-gray-900 `}>{t('app.answerIcon.title')}</div>
  208. <Switch
  209. defaultValue={inputInfo.use_icon_as_answer_icon}
  210. onChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
  211. />
  212. </div>
  213. <p className='body-xs-regular text-gray-500'>{t('app.answerIcon.description')}</p>
  214. </div>
  215. )}
  216. <div className={`mt-6 mb-2 font-medium ${s.settingTitle} text-gray-900 `}>{t(`${prefixSettings}.language`)}</div>
  217. <SimpleSelect
  218. items={languages.filter(item => item.supported)}
  219. defaultValue={language}
  220. onSelect={item => setLanguage(item.value as Language)}
  221. />
  222. <div className='w-full mt-8'>
  223. <p className='system-xs-medium text-gray-500'>{t(`${prefixSettings}.workflow.title`)}</p>
  224. <div className='flex justify-between items-center'>
  225. <div className='font-medium system-sm-semibold flex-grow text-gray-900'>{t(`${prefixSettings}.workflow.subTitle`)}</div>
  226. <Switch
  227. disabled={!(appInfo.mode === 'workflow' || appInfo.mode === 'advanced-chat')}
  228. defaultValue={inputInfo.show_workflow_steps}
  229. onChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
  230. />
  231. </div>
  232. <p className='body-xs-regular text-gray-500'>{t(`${prefixSettings}.workflow.showDesc`)}</p>
  233. </div>
  234. {isChat && <> <div className={`mt-8 font-medium ${s.settingTitle} text-gray-900`}>{t(`${prefixSettings}.chatColorTheme`)}</div>
  235. <p className={`mt-1 ${s.settingsTip} text-gray-500`}>{t(`${prefixSettings}.chatColorThemeDesc`)}</p>
  236. <input className={`w-full mt-2 rounded-lg h-10 box-border px-3 ${s.projectName} bg-gray-100`}
  237. value={inputInfo.chatColorTheme ?? ''}
  238. onChange={onChange('chatColorTheme')}
  239. placeholder='E.g #A020F0'
  240. />
  241. </>}
  242. {systemFeatures.enable_web_sso_switch_component && <div className='w-full mt-8'>
  243. <p className='system-xs-medium text-gray-500'>{t(`${prefixSettings}.sso.label`)}</p>
  244. <div className='flex justify-between items-center'>
  245. <div className='font-medium system-sm-semibold flex-grow text-gray-900'>{t(`${prefixSettings}.sso.title`)}</div>
  246. <Tooltip
  247. disabled={systemFeatures.sso_enforced_for_web}
  248. popupContent={
  249. <div className='w-[180px]'>{t(`${prefixSettings}.sso.tooltip`)}</div>
  250. }
  251. asChild={false}
  252. >
  253. <Switch disabled={!systemFeatures.sso_enforced_for_web || !isCurrentWorkspaceEditor} defaultValue={systemFeatures.sso_enforced_for_web && inputInfo.enable_sso} onChange={v => setInputInfo({ ...inputInfo, enable_sso: v })}></Switch>
  254. </Tooltip>
  255. </div>
  256. <p className='body-xs-regular text-gray-500'>{t(`${prefixSettings}.sso.description`)}</p>
  257. </div>}
  258. {!isShowMore && <div className='w-full cursor-pointer mt-8' onClick={() => setIsShowMore(true)}>
  259. <div className='flex justify-between'>
  260. <div className={`font-medium ${s.settingTitle} flex-grow text-gray-900`}>{t(`${prefixSettings}.more.entry`)}</div>
  261. <div className='flex-shrink-0 w-4 h-4 text-gray-500'>
  262. <ChevronRightIcon />
  263. </div>
  264. </div>
  265. <p className={`mt-1 ${s.policy} text-gray-500`}>{t(`${prefixSettings}.more.copyright`)} & {t(`${prefixSettings}.more.privacyPolicy`)}</p>
  266. </div>}
  267. {isShowMore && <>
  268. <hr className='w-full mt-6' />
  269. <div className={`mt-6 font-medium ${s.settingTitle} text-gray-900`}>{t(`${prefixSettings}.more.copyright`)}</div>
  270. <input className={`w-full mt-2 rounded-lg h-10 box-border px-3 ${s.projectName} bg-gray-100`}
  271. value={inputInfo.copyright}
  272. onChange={onChange('copyright')}
  273. placeholder={t(`${prefixSettings}.more.copyRightPlaceholder`) as string}
  274. />
  275. <div className={`mt-8 font-medium ${s.settingTitle} text-gray-900`}>{t(`${prefixSettings}.more.privacyPolicy`)}</div>
  276. <p className={`mt-1 ${s.settingsTip} text-gray-500`}>
  277. <Trans
  278. i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
  279. components={{ privacyPolicyLink: <Link href={'https://docs.dify.ai/user-agreement/privacy-policy'} target='_blank' rel='noopener noreferrer' className='text-primary-600' /> }}
  280. />
  281. </p>
  282. <input className={`w-full mt-2 rounded-lg h-10 box-border px-3 ${s.projectName} bg-gray-100`}
  283. value={inputInfo.privacyPolicy}
  284. onChange={onChange('privacyPolicy')}
  285. placeholder={t(`${prefixSettings}.more.privacyPolicyPlaceholder`) as string}
  286. />
  287. <div className={`mt-8 font-medium ${s.settingTitle} text-gray-900`}>{t(`${prefixSettings}.more.customDisclaimer`)}</div>
  288. <p className={`mt-1 ${s.settingsTip} text-gray-500`}>{t(`${prefixSettings}.more.customDisclaimerTip`)}</p>
  289. <input className={`w-full mt-2 rounded-lg h-10 box-border px-3 ${s.projectName} bg-gray-100`}
  290. value={inputInfo.customDisclaimer}
  291. onChange={onChange('customDisclaimer')}
  292. placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`) as string}
  293. />
  294. </>}
  295. <div className='mt-10 flex justify-end'>
  296. <Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button>
  297. <Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button>
  298. </div>
  299. {showAppIconPicker && <AppIconPicker
  300. onSelect={(payload) => {
  301. setAppIcon(payload)
  302. setShowAppIconPicker(false)
  303. }}
  304. onClose={() => {
  305. setAppIcon(icon_type === 'image'
  306. ? { type: 'image', url: icon_url!, fileId: icon }
  307. : { type: 'emoji', icon, background: icon_background! })
  308. setShowAppIconPicker(false)
  309. }}
  310. />}
  311. </Modal >
  312. </>
  313. )
  314. }
  315. export default React.memo(SettingsModal)