diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..d6a85f6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,34 +1,82 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import { TanstackQueryInitializer } from '@/context/query-client'; +import cn from '@/utils/classnames'; +import type { Viewport } from 'next'; +import { ThemeProvider } from 'next-themes'; +import { Instrument_Serif } from 'next/font/google'; +import './styles/globals.css'; +import './styles/markdown.scss'; +import BrowserInitializer from '@/components/browser-initializer'; +import RoutePrefixHandle from '@/components/routePrefixHandle'; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + viewportFit: 'cover', + userScalable: false, }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +const instrumentSerif = Instrument_Serif({ + weight: ['400'], + style: ['normal', 'italic'], + subsets: ['latin'], + variable: '--font-instrument-serif', +}); + +const LocaleLayout = async ({ children }: { children: React.ReactNode }) => { + const locale = await getLocaleOnServer(); + return ( - - - {children} + + + + + + + + + + + + + + + + + + + + + {children} + + + + + + ); -} +}; + +export default LocaleLayout; diff --git a/app/page.tsx b/app/page.tsx index 21b686d..ea5f923 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,103 +1,5 @@ -import Image from "next/image"; - export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - -
-
- -
+
); } diff --git a/components/base/action-button/index.css b/components/base/action-button/index.css new file mode 100644 index 0000000..0d08321 --- /dev/null +++ b/components/base/action-button/index.css @@ -0,0 +1,44 @@ +@tailwind utilities; + +@layer components { + .action-btn { + @apply inline-flex justify-center items-center cursor-pointer text-text-tertiary hover:text-text-secondary hover:bg-state-base-hover; + } + + .action-btn-hover { + @apply bg-state-base-hover; + } + + .action-btn-disabled { + @apply cursor-not-allowed; + } + + .action-btn-xl { + @apply p-2 w-9 h-9 rounded-lg; + } + + .action-btn-l { + @apply p-1.5 w-8 h-8 rounded-lg; + } + + /* m is for the regular button */ + .action-btn-m { + @apply p-0.5 w-6 h-6 rounded-lg; + } + + .action-btn-xs { + @apply p-0 w-4 h-4 rounded; + } + + .action-btn.action-btn-active { + @apply text-text-accent bg-state-accent-active hover:bg-state-accent-active-alt; + } + + .action-btn.action-btn-disabled { + @apply text-text-disabled; + } + + .action-btn.action-btn-destructive { + @apply text-text-destructive bg-state-destructive-hover; + } +} diff --git a/components/base/action-button/index.tsx b/components/base/action-button/index.tsx new file mode 100644 index 0000000..f70bfb4 --- /dev/null +++ b/components/base/action-button/index.tsx @@ -0,0 +1,72 @@ +import type { CSSProperties } from 'react' +import React from 'react' +import { type VariantProps, cva } from 'class-variance-authority' +import classNames from '@/utils/classnames' + +enum ActionButtonState { + Destructive = 'destructive', + Active = 'active', + Disabled = 'disabled', + Default = '', + Hover = 'hover', +} + +const actionButtonVariants = cva( + 'action-btn', + { + variants: { + size: { + xs: 'action-btn-xs', + m: 'action-btn-m', + l: 'action-btn-l', + xl: 'action-btn-xl', + }, + }, + defaultVariants: { + size: 'm', + }, + }, +) + +export type ActionButtonProps = { + size?: 'xs' | 's' | 'm' | 'l' | 'xl' + state?: ActionButtonState + styleCss?: CSSProperties + ref?: React.Ref +} & React.ButtonHTMLAttributes & VariantProps + +function getActionButtonState(state: ActionButtonState) { + switch (state) { + case ActionButtonState.Destructive: + return 'action-btn-destructive' + case ActionButtonState.Active: + return 'action-btn-active' + case ActionButtonState.Disabled: + return 'action-btn-disabled' + case ActionButtonState.Hover: + return 'action-btn-hover' + default: + return '' + } +} + +const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, ...props }: ActionButtonProps) => { + return ( + + ) +} +ActionButton.displayName = 'ActionButton' + +export default ActionButton +export { ActionButton, ActionButtonState, actionButtonVariants } diff --git a/components/base/app-unavailable.tsx b/components/base/app-unavailable.tsx new file mode 100644 index 0000000..c501d36 --- /dev/null +++ b/components/base/app-unavailable.tsx @@ -0,0 +1,32 @@ +'use client' +import classNames from '@/utils/classnames' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' + +type IAppUnavailableProps = { + code?: number | string + isUnknownReason?: boolean + unknownReason?: string + className?: string +} + +const AppUnavailable: FC = ({ + code = 404, + isUnknownReason, + unknownReason, + className, +}) => { + const { t } = useTranslation() + + return ( +
+

{code}

+
{unknownReason || (isUnknownReason ? t('share.common.appUnknownError') : t('share.common.appUnavailable'))}
+
+ ) +} +export default React.memo(AppUnavailable) diff --git a/components/base/chat/chat-with-history/chat-wrapper.tsx b/components/base/chat/chat-with-history/chat-wrapper.tsx new file mode 100644 index 0000000..29b27a6 --- /dev/null +++ b/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import Chat from '../chat' +import type { + ChatConfig, + ChatItem, + ChatItemInTree, + OnSend, +} from '../types' +import { useChat } from '../chat/hooks' +import { getLastAnswer, isValidGeneratedAnswer } from '../utils' +import { useChatWithHistoryContext } from './context' +import { InputVarType } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form' +import { + fetchSuggestedQuestions, + getUrl, + stopChatMessageResponding, +} from '@/service/share' +import AppIcon from '@/app/components/base/app-icon' +import AnswerIcon from '@/app/components/base/answer-icon' +import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions' +import { Markdown } from '@/app/components/base/markdown' +import cn from '@/utils/classnames' +import type { FileEntity } from '../../file-uploader/types' +import { formatBooleanInputs } from '@/utils/model-config' +import Avatar from '../../avatar' + +const ChatWrapper = () => { + const { + appParams, + appPrevChatTree, + currentConversationId, + currentConversationItem, + currentConversationInputs, + inputsForms, + newConversationInputs, + newConversationInputsRef, + handleNewConversationCompleted, + isMobile, + isInstalledApp, + appId, + appMeta, + handleFeedback, + currentChatInstanceRef, + appData, + themeBuilder, + sidebarCollapseState, + clearChatList, + setClearChatList, + setIsResponding, + allInputsHidden, + initUserVariables, + } = useChatWithHistoryContext() + + const appConfig = useMemo(() => { + const config = appParams || {} + + return { + ...config, + file_upload: { + ...(config as any).file_upload, + fileUploadConfig: (config as any).system_parameters, + }, + supportFeedback: true, + opening_statement: currentConversationItem?.introduction || (config as any).opening_statement, + } as ChatConfig + }, [appParams, currentConversationItem?.introduction]) + const { + chatList, + setTargetMessageId, + handleSend, + handleStop, + isResponding: respondingState, + suggestedQuestions, + } = useChat( + appConfig, + { + inputs: (currentConversationId ? currentConversationInputs : newConversationInputs) as any, + inputsForm: inputsForms, + }, + appPrevChatTree, + taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId), + clearChatList, + setClearChatList, + ) + const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current + const inputDisabled = useMemo(() => { + if (allInputsHidden) + return false + + let hasEmptyInput = '' + let fileIsUploading = false + const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) + if (requiredVars.length) { + requiredVars.forEach(({ variable, label, type }) => { + if (hasEmptyInput) + return + + if (fileIsUploading) + return + + if (!inputsFormValue?.[variable]) + hasEmptyInput = label as string + + if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable]) { + const files = inputsFormValue[variable] + if (Array.isArray(files)) + fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId) + else + fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId + } + }) + } + if (hasEmptyInput) + return true + + if (fileIsUploading) + return true + return false + }, [inputsFormValue, inputsForms, allInputsHidden]) + + useEffect(() => { + if (currentChatInstanceRef.current) + currentChatInstanceRef.current.handleStop = handleStop + }, []) + + useEffect(() => { + setIsResponding(respondingState) + }, [respondingState, setIsResponding]) + + const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { + const data: any = { + query: message, + files, + inputs: formatBooleanInputs(inputsForms, currentConversationId ? currentConversationInputs : newConversationInputs), + conversation_id: currentConversationId, + parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, + } + + handleSend( + getUrl('chat-messages', isInstalledApp, appId || ''), + data, + { + onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: !isInstalledApp, + }, + ) + }, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId]) + + const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { + const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find(item => item.id === question.parentMessageId) + doSend(editedQuestion ? editedQuestion.message : question.content, + editedQuestion ? editedQuestion.files : question.message_files, + true, + isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, + ) + }, [chatList, doSend]) + + const messageList = useMemo(() => { + if (currentConversationId || chatList.length > 1) + return chatList + // Without messages we are in the welcome screen, so hide the opening statement from chatlist + return chatList.filter(item => !item.isOpeningStatement) + }, [chatList]) + + const [collapsed, setCollapsed] = useState(!!currentConversationId) + + const chatNode = useMemo(() => { + if (allInputsHidden || !inputsForms.length) + return null + if (isMobile) { + if (!currentConversationId) + return + return null + } + else { + return + } + }, + [ + inputsForms.length, + isMobile, + currentConversationId, + collapsed, allInputsHidden, + ]) + + const welcome = useMemo(() => { + const welcomeMessage = chatList.find(item => item.isOpeningStatement) + if (respondingState) + return null + if (currentConversationId) + return null + if (!welcomeMessage) + return null + if (!collapsed && inputsForms.length > 0 && !allInputsHidden) + return null + if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) { + return ( +
+
+ +
+
+ + +
+
+
+
+ ) + } + return ( +
+ +
+ +
+
+ ) + }, + [ + appData?.site.icon, + appData?.site.icon_background, + appData?.site.icon_type, + appData?.site.icon_url, + chatList, collapsed, + currentConversationId, + inputsForms.length, + respondingState, + allInputsHidden, + ]) + + const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon) + ? + : null + + return ( +
+ + {chatNode} + {welcome} + + } + allToolIcons={appMeta?.tool_icons || {}} + onFeedback={handleFeedback} + suggestedQuestions={suggestedQuestions} + answerIcon={answerIcon} + hideProcessDetail + themeBuilder={themeBuilder} + switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)} + inputDisabled={inputDisabled} + isMobile={isMobile} + sidebarCollapseState={sidebarCollapseState} + questionIcon={ + initUserVariables?.avatar_url + ? : undefined + } + /> +
+ ) +} + +export default ChatWrapper diff --git a/components/base/chat/chat-with-history/context.tsx b/components/base/chat/chat-with-history/context.tsx new file mode 100644 index 0000000..03a0399 --- /dev/null +++ b/components/base/chat/chat-with-history/context.tsx @@ -0,0 +1,99 @@ +'use client' + +import type { RefObject } from 'react' +import { createContext, useContext } from 'use-context-selector' +import type { + Callback, + ChatConfig, + ChatItemInTree, + Feedback, +} from '../types' +import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context' +import type { + AppConversationData, + AppData, + AppMeta, + ConversationItem, +} from '@/models/share' +import { noop } from 'lodash-es' + +export type ChatWithHistoryContextValue = { + appMeta?: AppMeta | null + appData?: AppData | null + appParams?: ChatConfig + appChatListDataLoading?: boolean + currentConversationId: string + currentConversationItem?: ConversationItem + appPrevChatTree: ChatItemInTree[] + pinnedConversationList: AppConversationData['data'] + conversationList: AppConversationData['data'] + newConversationInputs: Record + newConversationInputsRef: RefObject> + handleNewConversationInputsChange: (v: Record) => void + inputsForms: any[] + handleNewConversation: () => void + handleStartChat: (callback?: any) => void + handleChangeConversation: (conversationId: string) => void + handlePinConversation: (conversationId: string) => void + handleUnpinConversation: (conversationId: string) => void + handleDeleteConversation: (conversationId: string, callback: Callback) => void + conversationRenaming: boolean + handleRenameConversation: (conversationId: string, newName: string, callback: Callback) => void + handleNewConversationCompleted: (newConversationId: string) => void + chatShouldReloadKey: string + isMobile: boolean + isInstalledApp: boolean + appId?: string + handleFeedback: (messageId: string, feedback: Feedback) => void + currentChatInstanceRef: RefObject<{ handleStop: () => void }> + themeBuilder?: ThemeBuilder + sidebarCollapseState?: boolean + handleSidebarCollapse: (state: boolean) => void + clearChatList?: boolean + setClearChatList: (state: boolean) => void + isResponding?: boolean + setIsResponding: (state: boolean) => void, + currentConversationInputs: Record | null, + setCurrentConversationInputs: (v: Record) => void, + allInputsHidden: boolean, + initUserVariables?: { + name?: string + avatar_url?: string + } +} + +export const ChatWithHistoryContext = createContext({ + currentConversationId: '', + appPrevChatTree: [], + pinnedConversationList: [], + conversationList: [], + newConversationInputs: {}, + newConversationInputsRef: { current: {} }, + handleNewConversationInputsChange: noop, + inputsForms: [], + handleNewConversation: noop, + handleStartChat: noop, + handleChangeConversation: noop, + handlePinConversation: noop, + handleUnpinConversation: noop, + handleDeleteConversation: noop, + conversationRenaming: false, + handleRenameConversation: noop, + handleNewConversationCompleted: noop, + chatShouldReloadKey: '', + isMobile: false, + isInstalledApp: false, + handleFeedback: noop, + currentChatInstanceRef: { current: { handleStop: noop } }, + sidebarCollapseState: false, + handleSidebarCollapse: noop, + clearChatList: false, + setClearChatList: noop, + isResponding: false, + setIsResponding: noop, + currentConversationInputs: {}, + setCurrentConversationInputs: noop, + allInputsHidden: false, + initUserVariables: {}, +}) +export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext) diff --git a/components/base/chat/chat-with-history/header-in-mobile.tsx b/components/base/chat/chat-with-history/header-in-mobile.tsx new file mode 100644 index 0000000..ec8da7b --- /dev/null +++ b/components/base/chat/chat-with-history/header-in-mobile.tsx @@ -0,0 +1,152 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiMenuLine, +} from '@remixicon/react' +import { useChatWithHistoryContext } from './context' +import Operation from './header/operation' +import Sidebar from './sidebar' +import MobileOperationDropdown from './header/mobile-operation-dropdown' +import AppIcon from '@/app/components/base/app-icon' +import ActionButton from '@/app/components/base/action-button' +import { Message3Fill } from '@/app/components/base/icons/src/public/other' +import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content' +import Confirm from '@/app/components/base/confirm' +import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal' +import type { ConversationItem } from '@/models/share' + +const HeaderInMobile = () => { + const { + appData, + currentConversationId, + currentConversationItem, + pinnedConversationList, + handleNewConversation, + handlePinConversation, + handleUnpinConversation, + handleDeleteConversation, + handleRenameConversation, + conversationRenaming, + inputsForms, + } = useChatWithHistoryContext() + const { t } = useTranslation() + const isPin = pinnedConversationList.some(item => item.id === currentConversationId) + const [showConfirm, setShowConfirm] = useState(null) + const [showRename, setShowRename] = useState(null) + const handleOperate = useCallback((type: string) => { + if (type === 'pin') + handlePinConversation(currentConversationId) + + if (type === 'unpin') + handleUnpinConversation(currentConversationId) + + if (type === 'delete') + setShowConfirm(currentConversationItem as any) + + if (type === 'rename') + setShowRename(currentConversationItem as any) + }, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation]) + const handleCancelConfirm = useCallback(() => { + setShowConfirm(null) + }, []) + const handleDelete = useCallback(() => { + if (showConfirm) + handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm }) + }, [showConfirm, handleDeleteConversation, handleCancelConfirm]) + const handleCancelRename = useCallback(() => { + setShowRename(null) + }, []) + const handleRename = useCallback((newName: string) => { + if (showRename) + handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) + }, [showRename, handleRenameConversation, handleCancelRename]) + const [showSidebar, setShowSidebar] = useState(false) + const [showChatSettings, setShowChatSettings] = useState(false) + + return ( + <> +
+ setShowSidebar(true)}> + + +
+ {!currentConversationId && ( + <> + +
+ {appData?.site.title} +
+ + )} + {currentConversationId && ( + handleOperate(isPin ? 'unpin' : 'pin')} + isShowDelete + isShowRenameConversation + onRenameConversation={() => handleOperate('rename')} + onDelete={() => handleOperate('delete')} + /> + )} +
+ setShowChatSettings(true)} + hideViewChatSettings={inputsForms.length < 1} + /> +
+ {showSidebar && ( +
setShowSidebar(false)} + > +
e.stopPropagation()}> + +
+
+ )} + {showChatSettings && ( +
setShowChatSettings(false)} + > +
e.stopPropagation()}> +
+ +
{t('share.chat.chatSettingsTitle')}
+
+
+ +
+
+
+ )} + {!!showConfirm && ( + + )} + {showRename && ( + + )} + + ) +} + +export default HeaderInMobile diff --git a/components/base/chat/chat-with-history/header/index.tsx b/components/base/chat/chat-with-history/header/index.tsx new file mode 100644 index 0000000..b5c5bcc --- /dev/null +++ b/components/base/chat/chat-with-history/header/index.tsx @@ -0,0 +1,164 @@ +import { useCallback, useState } from 'react' +import { + RiEditBoxLine, + RiLayoutRight2Line, + RiResetLeftLine, +} from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { + useChatWithHistoryContext, +} from '../context' +import Operation from './operation' +import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' +import AppIcon from '@/app/components/base/app-icon' +import Tooltip from '@/app/components/base/tooltip' +import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown' +import Confirm from '@/app/components/base/confirm' +import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal' +import type { ConversationItem } from '@/models/share' +import cn from '@/utils/classnames' + +const Header = () => { + const { + appData, + currentConversationId, + currentConversationItem, + inputsForms, + pinnedConversationList, + handlePinConversation, + handleUnpinConversation, + conversationRenaming, + handleRenameConversation, + handleDeleteConversation, + handleNewConversation, + sidebarCollapseState, + handleSidebarCollapse, + isResponding, + } = useChatWithHistoryContext() + const { t } = useTranslation() + const isSidebarCollapsed = sidebarCollapseState + + const isPin = pinnedConversationList.some(item => item.id === currentConversationId) + + const [showConfirm, setShowConfirm] = useState(null) + const [showRename, setShowRename] = useState(null) + const handleOperate = useCallback((type: string) => { + if (type === 'pin') + handlePinConversation(currentConversationId) + + if (type === 'unpin') + handleUnpinConversation(currentConversationId) + + if (type === 'delete') + setShowConfirm(currentConversationItem as any) + + if (type === 'rename') + setShowRename(currentConversationItem as any) + }, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation]) + const handleCancelConfirm = useCallback(() => { + setShowConfirm(null) + }, []) + const handleDelete = useCallback(() => { + if (showConfirm) + handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm }) + }, [showConfirm, handleDeleteConversation, handleCancelConfirm]) + const handleCancelRename = useCallback(() => { + setShowRename(null) + }, []) + const handleRename = useCallback((newName: string) => { + if (showRename) + handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) + }, [showRename, handleRenameConversation, handleCancelRename]) + + return ( + <> +
+
+ handleSidebarCollapse(false)}> + + +
+ +
+ {!currentConversationId && ( +
{appData?.site.title}
+ )} + {currentConversationId && currentConversationItem && isSidebarCollapsed && ( + <> +
/
+ handleOperate(isPin ? 'unpin' : 'pin')} + isShowDelete + isShowRenameConversation + onRenameConversation={() => handleOperate('rename')} + onDelete={() => handleOperate('delete')} + /> + + )} +
+
+
+ {isSidebarCollapsed && ( + +
+ + + +
+
+ )} +
+
+ {currentConversationId && ( + + + + + + )} + {currentConversationId && inputsForms.length > 0 && ( + + )} +
+
+ {!!showConfirm && ( + + )} + {showRename && ( + + )} + + ) +} + +export default Header diff --git a/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx b/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx new file mode 100644 index 0000000..4bb6940 --- /dev/null +++ b/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiMoreFill, +} from '@remixicon/react' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' + +type Props = { + handleResetChat: () => void + handleViewChatSettings: () => void + hideViewChatSettings?: boolean +} + +const MobileOperationDropdown = ({ + handleResetChat, + handleViewChatSettings, + hideViewChatSettings = false, +}: Props) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + return ( + + setOpen(v => !v)} + > + + + + + +
+
+ {t('share.chat.resetChat')} +
+ {!hideViewChatSettings && ( +
+ {t('share.chat.viewChatSettings')} +
+ )} +
+
+
+ + ) +} + +export default MobileOperationDropdown diff --git a/components/base/chat/chat-with-history/header/operation.tsx b/components/base/chat/chat-with-history/header/operation.tsx new file mode 100644 index 0000000..0923d71 --- /dev/null +++ b/components/base/chat/chat-with-history/header/operation.tsx @@ -0,0 +1,73 @@ +'use client' +import type { FC } from 'react' +import React, { useState } from 'react' +import type { Placement } from '@floating-ui/react' +import { + RiArrowDownSLine, +} from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import cn from '@/utils/classnames' + +type Props = { + title: string + isPinned: boolean + isShowRenameConversation?: boolean + onRenameConversation?: () => void + isShowDelete: boolean + togglePin: () => void + onDelete: () => void + placement?: Placement +} + +const Operation: FC = ({ + title, + isPinned, + togglePin, + isShowRenameConversation, + onRenameConversation, + isShowDelete, + onDelete, + placement = 'bottom-start', +}) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + return ( + + setOpen(v => !v)} + > +
+
{title}
+ +
+
+ +
+
+ {isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')} +
+ {isShowRenameConversation && ( +
+ {t('explore.sidebar.action.rename')} +
+ )} + {isShowDelete && ( +
+ {t('explore.sidebar.action.delete')} +
+ )} +
+
+
+ ) +} +export default React.memo(Operation) diff --git a/components/base/chat/chat-with-history/hooks.tsx b/components/base/chat/chat-with-history/hooks.tsx new file mode 100644 index 0000000..c17ab26 --- /dev/null +++ b/components/base/chat/chat-with-history/hooks.tsx @@ -0,0 +1,576 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import useSWR from 'swr' +import { useLocalStorageState } from 'ahooks' +import produce from 'immer' +import type { + Callback, + ChatConfig, + ChatItem, + Feedback, +} from '../types' +import { CONVERSATION_ID_INFO } from '../constants' +import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils' +import { addFileInfos, sortAgentSorts } from '../../../tools/utils' +import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' +import { + delConversation, + fetchChatList, + fetchConversations, + generationConversationName, + pinConversation, + renameConversation, + unpinConversation, + updateFeedback, +} from '@/service/share' +import type { InstalledApp } from '@/models/explore' +import type { + AppData, + ConversationItem, +} from '@/models/share' +import { useToastContext } from '@/app/components/base/toast' +import { changeLanguage } from '@/i18n-config/i18next-config' +import { useAppFavicon } from '@/hooks/use-app-favicon' +import { InputVarType } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import { noop } from 'lodash-es' +import { useWebAppStore } from '@/context/web-app-context' + +function getFormattedChatList(messages: any[]) { + const newChatList: ChatItem[] = [] + messages.forEach((item) => { + const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] + newChatList.push({ + id: `question-${item.id}`, + content: item.query, + isAnswer: false, + message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))), + parentMessageId: item.parent_message_id || undefined, + }) + const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] + newChatList.push({ + id: item.id, + content: item.answer, + agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files), + feedback: item.feedback, + isAnswer: true, + citation: item.retriever_resources, + message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))), + parentMessageId: `question-${item.id}`, + }) + }) + return newChatList +} + +export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { + const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) + const appInfo = useWebAppStore(s => s.appInfo) + const appParams = useWebAppStore(s => s.appParams) + const appMeta = useWebAppStore(s => s.appMeta) + + useAppFavicon({ + enable: !installedAppInfo, + icon_type: appInfo?.site.icon_type, + icon: appInfo?.site.icon, + icon_background: appInfo?.site.icon_background, + icon_url: appInfo?.site.icon_url, + }) + + const appData = useMemo(() => { + if (isInstalledApp) { + const { id, app } = installedAppInfo! + return { + app_id: id, + site: { + title: app.name, + icon_type: app.icon_type, + icon: app.icon, + icon_background: app.icon_background, + icon_url: app.icon_url, + prompt_public: false, + copyright: '', + show_workflow_steps: true, + use_icon_as_answer_icon: app.use_icon_as_answer_icon, + }, + plan: 'basic', + custom_config: null, + } as AppData + } + + return appInfo + }, [isInstalledApp, installedAppInfo, appInfo]) + const appId = useMemo(() => appData?.app_id, [appData]) + + const [userId, setUserId] = useState() + useEffect(() => { + getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => { + setUserId(user_id) + }) + }, []) + + useEffect(() => { + const setLocaleFromProps = async () => { + if (appData?.site.default_language) + await changeLanguage(appData.site.default_language) + } + setLocaleFromProps() + }, [appData]) + + const [sidebarCollapseState, setSidebarCollapseState] = useState(() => { + if (typeof window !== 'undefined') { + try { + const localState = localStorage.getItem('webappSidebarCollapse') + return localState === 'collapsed' + } + catch { + // localStorage may be disabled in private browsing mode or by security settings + // fallback to default value + return false + } + } + return false + }) + const handleSidebarCollapse = useCallback((state: boolean) => { + if (appId) { + setSidebarCollapseState(state) + try { + localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded') + } + catch { + // localStorage may be disabled, continue without persisting state + } + } + }, [appId, setSidebarCollapseState]) + const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState>>(CONVERSATION_ID_INFO, { + defaultValue: {}, + }) + const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId]) + const handleConversationIdInfoChange = useCallback((changeConversationId: string) => { + if (appId) { + let prevValue = conversationIdInfo?.[appId || ''] + if (typeof prevValue === 'string') + prevValue = {} + setConversationIdInfo({ + ...conversationIdInfo, + [appId || '']: { + ...prevValue, + [userId || 'DEFAULT']: changeConversationId, + }, + }) + } + }, [appId, conversationIdInfo, setConversationIdInfo, userId]) + + const [newConversationId, setNewConversationId] = useState('') + const chatShouldReloadKey = useMemo(() => { + if (currentConversationId === newConversationId) + return '' + + return currentConversationId + }, [currentConversationId, newConversationId]) + + const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR( + appId ? ['appConversationData', isInstalledApp, appId, true] : null, + () => fetchConversations(isInstalledApp, appId, undefined, true, 100), + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ) + const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR( + appId ? ['appConversationData', isInstalledApp, appId, false] : null, + () => fetchConversations(isInstalledApp, appId, undefined, false, 100), + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ) + const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR( + chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, + () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId), + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ) + + const [clearChatList, setClearChatList] = useState(false) + const [isResponding, setIsResponding] = useState(false) + const appPrevChatTree = useMemo( + () => (currentConversationId && appChatListData?.data.length) + ? buildChatItemTree(getFormattedChatList(appChatListData.data)) + : [], + [appChatListData, currentConversationId], + ) + + const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false) + + const pinnedConversationList = useMemo(() => { + return appPinnedConversationData?.data || [] + }, [appPinnedConversationData]) + const { t } = useTranslation() + const newConversationInputsRef = useRef>({}) + const [newConversationInputs, setNewConversationInputs] = useState>({}) + const [initInputs, setInitInputs] = useState>({}) + const [initUserVariables, setInitUserVariables] = useState>({}) + const handleNewConversationInputsChange = useCallback((newInputs: Record) => { + newConversationInputsRef.current = newInputs + setNewConversationInputs(newInputs) + }, []) + const inputsForms = useMemo(() => { + return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => { + if (item.paragraph) { + let value = initInputs[item.paragraph.variable] + if (value && item.paragraph.max_length && value.length > item.paragraph.max_length) + value = value.slice(0, item.paragraph.max_length) + + return { + ...item.paragraph, + default: value || item.default || item.paragraph.default, + type: 'paragraph', + } + } + if (item.number) { + const convertedNumber = Number(initInputs[item.number.variable]) + return { + ...item.number, + default: convertedNumber || item.default || item.number.default, + type: 'number', + } + } + + if (item.checkbox) { + const preset = initInputs[item.checkbox.variable] === true + return { + ...item.checkbox, + default: preset || item.default || item.checkbox.default, + type: 'checkbox', + } + } + + if (item.select) { + const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) + return { + ...item.select, + default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.select.default, + type: 'select', + } + } + + if (item['file-list']) { + return { + ...item['file-list'], + type: 'file-list', + } + } + + if (item.file) { + return { + ...item.file, + type: 'file', + } + } + + if (item.json_object) { + return { + ...item.json_object, + type: 'json_object', + } + } + + let value = initInputs[item['text-input'].variable] + if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) + value = value.slice(0, item['text-input'].max_length) + + return { + ...item['text-input'], + default: value || item.default || item['text-input'].default, + type: 'text-input', + } + }) + }, [initInputs, appParams]) + + const allInputsHidden = useMemo(() => { + return inputsForms.length > 0 && inputsForms.every(item => item.hide === true) + }, [inputsForms]) + + useEffect(() => { + // init inputs from url params + (async () => { + const inputs = await getRawInputsFromUrlParams() + const userVariables = await getRawUserVariablesFromUrlParams() + setInitInputs(inputs) + setInitUserVariables(userVariables) + })() + }, []) + + useEffect(() => { + const conversationInputs: Record = {} + + inputsForms.forEach((item: any) => { + conversationInputs[item.variable] = item.default || null + }) + handleNewConversationInputsChange(conversationInputs) + }, [handleNewConversationInputsChange, inputsForms]) + + const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false }) + const [originConversationList, setOriginConversationList] = useState([]) + useEffect(() => { + if (appConversationData?.data && !appConversationDataLoading) + setOriginConversationList(appConversationData?.data) + }, [appConversationData, appConversationDataLoading]) + const conversationList = useMemo(() => { + const data = originConversationList.slice() + + if (showNewConversationItemInList && data[0]?.id !== '') { + data.unshift({ + id: '', + name: t('share.chat.newChatDefaultName'), + inputs: {}, + introduction: '', + }) + } + return data + }, [originConversationList, showNewConversationItemInList, t]) + + useEffect(() => { + if (newConversation) { + setOriginConversationList(produce((draft) => { + const index = draft.findIndex(item => item.id === newConversation.id) + + if (index > -1) + draft[index] = newConversation + else + draft.unshift(newConversation) + })) + } + }, [newConversation]) + + const currentConversationItem = useMemo(() => { + let conversationItem = conversationList.find(item => item.id === currentConversationId) + + if (!conversationItem && pinnedConversationList.length) + conversationItem = pinnedConversationList.find(item => item.id === currentConversationId) + + return conversationItem + }, [conversationList, currentConversationId, pinnedConversationList]) + + const currentConversationLatestInputs = useMemo(() => { + if (!currentConversationId || !appChatListData?.data.length) + return newConversationInputsRef.current || {} + return appChatListData.data.slice().pop().inputs || {} + }, [appChatListData, currentConversationId]) + const [currentConversationInputs, setCurrentConversationInputs] = useState>(currentConversationLatestInputs || {}) + useEffect(() => { + if (currentConversationItem) + setCurrentConversationInputs(currentConversationLatestInputs || {}) + }, [currentConversationItem, currentConversationLatestInputs]) + + const { notify } = useToastContext() + const checkInputsRequired = useCallback((silent?: boolean) => { + if (allInputsHidden) + return true + + let hasEmptyInput = '' + let fileIsUploading = false + const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) + if (requiredVars.length) { + requiredVars.forEach(({ variable, label, type }) => { + if (hasEmptyInput) + return + + if (fileIsUploading) + return + + if (!newConversationInputsRef.current[variable] && !silent) + hasEmptyInput = label as string + + if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) { + const files = newConversationInputsRef.current[variable] + if (Array.isArray(files)) + fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId) + else + fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId + } + }) + } + + if (hasEmptyInput) { + notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) }) + return false + } + + if (fileIsUploading) { + notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') }) + return + } + + return true + }, [inputsForms, notify, t, allInputsHidden]) + const handleStartChat = useCallback((callback: any) => { + if (checkInputsRequired()) { + setShowNewConversationItemInList(true) + callback?.() + } + }, [setShowNewConversationItemInList, checkInputsRequired]) + const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop }) + const handleChangeConversation = useCallback((conversationId: string) => { + currentChatInstanceRef.current.handleStop() + setNewConversationId('') + handleConversationIdInfoChange(conversationId) + if (conversationId) + setClearChatList(false) + }, [handleConversationIdInfoChange, setClearChatList]) + const handleNewConversation = useCallback(async () => { + currentChatInstanceRef.current.handleStop() + setShowNewConversationItemInList(true) + handleChangeConversation('') + const conversationInputs: Record = {} + inputsForms.forEach((item: any) => { + conversationInputs[item.variable] = item.default || null + }) + handleNewConversationInputsChange(conversationInputs) + setClearChatList(true) + }, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList, inputsForms]) + const handleUpdateConversationList = useCallback(() => { + mutateAppConversationData() + mutateAppPinnedConversationData() + }, [mutateAppConversationData, mutateAppPinnedConversationData]) + + const handlePinConversation = useCallback(async (conversationId: string) => { + await pinConversation(isInstalledApp, appId, conversationId) + notify({ type: 'success', message: t('common.api.success') }) + handleUpdateConversationList() + }, [isInstalledApp, appId, notify, t, handleUpdateConversationList]) + + const handleUnpinConversation = useCallback(async (conversationId: string) => { + await unpinConversation(isInstalledApp, appId, conversationId) + notify({ type: 'success', message: t('common.api.success') }) + handleUpdateConversationList() + }, [isInstalledApp, appId, notify, t, handleUpdateConversationList]) + + const [conversationDeleting, setConversationDeleting] = useState(false) + const handleDeleteConversation = useCallback(async ( + conversationId: string, + { + onSuccess, + }: Callback, + ) => { + if (conversationDeleting) + return + + try { + setConversationDeleting(true) + await delConversation(isInstalledApp, appId, conversationId) + notify({ type: 'success', message: t('common.api.success') }) + onSuccess() + } + finally { + setConversationDeleting(false) + } + + if (conversationId === currentConversationId) + handleNewConversation() + + handleUpdateConversationList() + }, [isInstalledApp, appId, notify, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting]) + + const [conversationRenaming, setConversationRenaming] = useState(false) + const handleRenameConversation = useCallback(async ( + conversationId: string, + newName: string, + { + onSuccess, + }: Callback, + ) => { + if (conversationRenaming) + return + + if (!newName.trim()) { + notify({ + type: 'error', + message: t('common.chat.conversationNameCanNotEmpty'), + }) + return + } + + setConversationRenaming(true) + try { + await renameConversation(isInstalledApp, appId, conversationId, newName) + + notify({ + type: 'success', + message: t('common.actionMsg.modifiedSuccessfully'), + }) + setOriginConversationList(produce((draft) => { + const index = originConversationList.findIndex(item => item.id === conversationId) + const item = draft[index] + + draft[index] = { + ...item, + name: newName, + } + })) + onSuccess() + } + finally { + setConversationRenaming(false) + } + }, [isInstalledApp, appId, notify, t, conversationRenaming, originConversationList]) + + const handleNewConversationCompleted = useCallback((newConversationId: string) => { + setNewConversationId(newConversationId) + handleConversationIdInfoChange(newConversationId) + setShowNewConversationItemInList(false) + mutateAppConversationData() + }, [mutateAppConversationData, handleConversationIdInfoChange]) + + const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { + await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId) + notify({ type: 'success', message: t('common.api.success') }) + }, [isInstalledApp, appId, t, notify]) + + return { + isInstalledApp, + appId, + currentConversationId, + currentConversationItem, + handleConversationIdInfoChange, + appData, + appParams: appParams || {} as ChatConfig, + appMeta, + appPinnedConversationData, + appConversationData, + appConversationDataLoading, + appChatListData, + appChatListDataLoading, + appPrevChatTree, + pinnedConversationList, + conversationList, + setShowNewConversationItemInList, + newConversationInputs, + newConversationInputsRef, + handleNewConversationInputsChange, + inputsForms, + handleNewConversation, + handleStartChat, + handleChangeConversation, + handlePinConversation, + handleUnpinConversation, + conversationDeleting, + handleDeleteConversation, + conversationRenaming, + handleRenameConversation, + handleNewConversationCompleted, + newConversationId, + chatShouldReloadKey, + handleFeedback, + currentChatInstanceRef, + sidebarCollapseState, + handleSidebarCollapse, + clearChatList, + setClearChatList, + isResponding, + setIsResponding, + currentConversationInputs, + setCurrentConversationInputs, + allInputsHidden, + initUserVariables, + } +} diff --git a/components/base/chat/chat-with-history/index.tsx b/components/base/chat/chat-with-history/index.tsx new file mode 100644 index 0000000..464e30a --- /dev/null +++ b/components/base/chat/chat-with-history/index.tsx @@ -0,0 +1,242 @@ +'use client' +import type { FC } from 'react' +import { + useEffect, + useState, +} from 'react' +import { useAsyncEffect } from 'ahooks' +import { useThemeContext } from '../embedded-chatbot/theme/theme-context' +import { + ChatWithHistoryContext, + useChatWithHistoryContext, +} from './context' +import { useChatWithHistory } from './hooks' +import Sidebar from './sidebar' +import Header from './header' +import HeaderInMobile from './header-in-mobile' +import ChatWrapper from './chat-wrapper' +import type { InstalledApp } from '@/models/explore' +import Loading from '@/app/components/base/loading' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { checkOrSetAccessToken } from '@/app/components/share/utils' +import AppUnavailable from '@/app/components/base/app-unavailable' +import cn from '@/utils/classnames' +import useDocumentTitle from '@/hooks/use-document-title' + +type ChatWithHistoryProps = { + className?: string +} +const ChatWithHistory: FC = ({ + className, +}) => { + const { + appData, + appChatListDataLoading, + chatShouldReloadKey, + isMobile, + themeBuilder, + sidebarCollapseState, + } = useChatWithHistoryContext() + const isSidebarCollapsed = sidebarCollapseState + const customConfig = appData?.custom_config + const site = appData?.site + + const [showSidePanel, setShowSidePanel] = useState(false) + + useEffect(() => { + themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted) + }, [site, customConfig, themeBuilder]) + + useEffect(() => { + if (!isSidebarCollapsed) + setShowSidePanel(false) + }, [isSidebarCollapsed]) + + useDocumentTitle(site?.title || 'Chat') + + return ( +
+ {!isMobile && ( +
+ +
+ )} + {isMobile && ( + + )} +
+ {isSidebarCollapsed && ( +
setShowSidePanel(true)} + onMouseLeave={() => setShowSidePanel(false)} + > + +
+ )} +
+ {!isMobile &&
} + {appChatListDataLoading && ( + + )} + {!appChatListDataLoading && ( + + )} +
+
+
+ ) +} + +export type ChatWithHistoryWrapProps = { + installedAppInfo?: InstalledApp + className?: string +} +const ChatWithHistoryWrap: FC = ({ + installedAppInfo, + className, +}) => { + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + const themeBuilder = useThemeContext() + + const { + appData, + appParams, + appMeta, + appChatListDataLoading, + currentConversationId, + currentConversationItem, + appPrevChatTree, + pinnedConversationList, + conversationList, + newConversationInputs, + newConversationInputsRef, + handleNewConversationInputsChange, + inputsForms, + handleNewConversation, + handleStartChat, + handleChangeConversation, + handlePinConversation, + handleUnpinConversation, + handleDeleteConversation, + conversationRenaming, + handleRenameConversation, + handleNewConversationCompleted, + chatShouldReloadKey, + isInstalledApp, + appId, + handleFeedback, + currentChatInstanceRef, + sidebarCollapseState, + handleSidebarCollapse, + clearChatList, + setClearChatList, + isResponding, + setIsResponding, + currentConversationInputs, + setCurrentConversationInputs, + allInputsHidden, + initUserVariables, + } = useChatWithHistory(installedAppInfo) + + return ( + + + + ) +} + +const ChatWithHistoryWrapWithCheckToken: FC = ({ + installedAppInfo, + className, +}) => { + const [initialized, setInitialized] = useState(false) + const [appUnavailable, setAppUnavailable] = useState(false) + const [isUnknownReason, setIsUnknownReason] = useState(false) + + useAsyncEffect(async () => { + if (!initialized) { + if (!installedAppInfo) { + try { + await checkOrSetAccessToken() + } + catch (e: any) { + if (e.status === 404) { + setAppUnavailable(true) + } + else { + setIsUnknownReason(true) + setAppUnavailable(true) + } + } + } + setInitialized(true) + } + }, []) + + if (!initialized) + return null + + if (appUnavailable) + return + + return ( + + ) +} + +export default ChatWithHistoryWrapWithCheckToken diff --git a/components/base/chat/chat-with-history/inputs-form/content.tsx b/components/base/chat/chat-with-history/inputs-form/content.tsx new file mode 100644 index 0000000..392bdf2 --- /dev/null +++ b/components/base/chat/chat-with-history/inputs-form/content.tsx @@ -0,0 +1,142 @@ +import React, { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useChatWithHistoryContext } from '../context' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' +import { PortalSelect } from '@/app/components/base/select' +import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import { InputVarType } from '@/app/components/workflow/types' +import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' + +type Props = { + showTip?: boolean +} + +const InputsFormContent = ({ showTip }: Props) => { + const { t } = useTranslation() + const { + appParams, + inputsForms, + currentConversationId, + currentConversationInputs, + setCurrentConversationInputs, + newConversationInputs, + newConversationInputsRef, + handleNewConversationInputsChange, + } = useChatWithHistoryContext() + const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputs + + const handleFormChange = useCallback((variable: string, value: any) => { + setCurrentConversationInputs({ + ...currentConversationInputs, + [variable]: value, + }) + handleNewConversationInputsChange({ + ...newConversationInputsRef.current, + [variable]: value, + }) + }, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs]) + + const visibleInputsForms = inputsForms.filter(form => form.hide !== true) + + return ( +
+ {visibleInputsForms.map(form => ( +
+ {form.type !== InputVarType.checkbox && ( +
+
{form.label}
+ {!form.required && ( +
{t('appDebug.variableTable.optional')}
+ )} +
+ )} + {form.type === InputVarType.textInput && ( + handleFormChange(form.variable, e.target.value)} + placeholder={form.label} + /> + )} + {form.type === InputVarType.number && ( + handleFormChange(form.variable, e.target.value)} + placeholder={form.label} + /> + )} + {form.type === InputVarType.paragraph && ( +