Initial commit

This commit is contained in:
2025-10-14 14:17:21 +08:00
commit ac715a8b88
35011 changed files with 3834178 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
@tailwind components;
@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-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
}
}

View File

@@ -0,0 +1,70 @@
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 = '',
}
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' | 'm' | 'l' | 'xl'
state?: ActionButtonState
styleCss?: CSSProperties
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof actionButtonVariants>
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'
default:
return ''
}
}
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
({ className, size, state = ActionButtonState.Default, styleCss, children, ...props }, ref) => {
return (
<button
type='button'
className={classNames(
actionButtonVariants({ className, size }),
getActionButtonState(state),
)}
ref={ref}
style={styleCss}
{...props}
>
{children}
</button>
)
},
)
ActionButton.displayName = 'ActionButton'
export default ActionButton
export { ActionButton, ActionButtonState, actionButtonVariants }

View File

@@ -0,0 +1,132 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import { flatten, uniq } from 'lodash-es'
import ResultPanel from './result'
import TracingPanel from './tracing'
import cn from '@/utils/classnames'
import { ToastContext } from '@/app/components/base/toast'
import Loading from '@/app/components/base/loading'
import { fetchAgentLogDetail } from '@/service/log'
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
export type AgentLogDetailProps = {
activeTab?: 'DETAIL' | 'TRACING'
conversationID: string
log: IChatItem
messageID: string
}
const AgentLogDetail: FC<AgentLogDetailProps> = ({
activeTab = 'DETAIL',
conversationID,
messageID,
log,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentTab, setCurrentTab] = useState<string>(activeTab)
const appDetail = useAppStore(s => s.appDetail)
const [loading, setLoading] = useState<boolean>(true)
const [runDetail, setRunDetail] = useState<AgentLogDetailResponse>()
const [list, setList] = useState<AgentIteration[]>([])
const tools = useMemo(() => {
const res = uniq(flatten(runDetail?.iterations.map((iteration: any) => {
return iteration.tool_calls.map((tool: any) => tool.tool_name).filter(Boolean)
})).filter(Boolean))
return res
}, [runDetail])
const getLogDetail = useCallback(async (appID: string, conversationID: string, messageID: string) => {
try {
const res = await fetchAgentLogDetail({
appID,
params: {
conversation_id: conversationID,
message_id: messageID,
},
})
setRunDetail(res)
setList(res.iterations)
}
catch (err) {
notify({
type: 'error',
message: `${err}`,
})
}
}, [notify])
const getData = async (appID: string, conversationID: string, messageID: string) => {
setLoading(true)
await getLogDetail(appID, conversationID, messageID)
setLoading(false)
}
const switchTab = async (tab: string) => {
setCurrentTab(tab)
}
useEffect(() => {
// fetch data
if (appDetail)
getData(appDetail.id, conversationID, messageID)
}, [appDetail, conversationID, messageID])
return (
<div className='grow relative flex flex-col'>
{/* tab */}
<div className='shrink-0 flex items-center px-4 border-b-[0.5px] border-[rgba(0,0,0,0.05)]'>
<div
className={cn(
'mr-6 py-3 border-b-2 border-transparent text-[13px] font-semibold leading-[18px] text-gray-400 cursor-pointer',
currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-gray-700',
)}
onClick={() => switchTab('DETAIL')}
>{t('runLog.detail')}</div>
<div
className={cn(
'mr-6 py-3 border-b-2 border-transparent text-[13px] font-semibold leading-[18px] text-gray-400 cursor-pointer',
currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-gray-700',
)}
onClick={() => switchTab('TRACING')}
>{t('runLog.tracing')}</div>
</div>
{/* panel detail */}
<div className={cn('grow bg-white h-0 overflow-y-auto rounded-b-2xl', currentTab !== 'DETAIL' && '!bg-gray-50')}>
{loading && (
<div className='flex h-full items-center justify-center bg-white'>
<Loading />
</div>
)}
{!loading && currentTab === 'DETAIL' && runDetail && (
<ResultPanel
inputs={log.input}
outputs={log.content}
status={runDetail.meta.status}
error={runDetail.meta.error}
elapsed_time={runDetail.meta.elapsed_time}
total_tokens={runDetail.meta.total_tokens}
created_at={runDetail.meta.start_time}
created_by={runDetail.meta.executor}
agentMode={runDetail.meta.agent_mode}
tools={tools}
iterations={runDetail.iterations.length}
/>
)}
{!loading && currentTab === 'TRACING' && (
<TracingPanel
list={list}
/>
)}
</div>
</div>
)
}
export default AgentLogDetail

View File

@@ -0,0 +1,61 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import { useEffect, useRef, useState } from 'react'
import { useClickAway } from 'ahooks'
import AgentLogDetail from './detail'
import cn from '@/utils/classnames'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
type AgentLogModalProps = {
currentLogItem?: IChatItem
width: number
onCancel: () => void
}
const AgentLogModal: FC<AgentLogModalProps> = ({
currentLogItem,
width,
onCancel,
}) => {
const { t } = useTranslation()
const ref = useRef(null)
const [mounted, setMounted] = useState(false)
useClickAway(() => {
if (mounted)
onCancel()
}, ref)
useEffect(() => {
setMounted(true)
}, [])
if (!currentLogItem || !currentLogItem.conversationId)
return null
return (
<div
className={cn('relative flex flex-col py-3 bg-white border-[0.5px] border-gray-200 rounded-xl shadow-xl z-10')}
style={{
width: 480,
position: 'fixed',
top: 56 + 8,
left: 8 + (width - 480),
bottom: 16,
}}
ref={ref}
>
<h1 className='shrink-0 px-4 py-1 text-md font-semibold text-gray-900'>{t('appLog.runDetail.workflowTitle')}</h1>
<span className='absolute right-3 top-4 p-1 cursor-pointer z-20' onClick={onCancel}>
<RiCloseLine className='w-4 h-4 text-gray-500' />
</span>
<AgentLogDetail
conversationID={currentLogItem.conversationId}
messageID={currentLogItem.id}
log={currentLogItem}
/>
</div>
)
}
export default AgentLogModal

View File

@@ -0,0 +1,50 @@
'use client'
import { useTranslation } from 'react-i18next'
import type { FC } from 'react'
import ToolCall from './tool-call'
import cn from '@/utils/classnames'
import type { AgentIteration } from '@/models/log'
type Props = {
isFinal: boolean
index: number
iterationInfo: AgentIteration
}
const Iteration: FC<Props> = ({ iterationInfo, isFinal, index }) => {
const { t } = useTranslation()
return (
<div className={cn('px-4 py-2')}>
<div className='flex items-center'>
{isFinal && (
<div className='shrink-0 mr-3 text-gray-500 text-xs leading-[18px] font-semibold'>{t('appLog.agentLogDetail.finalProcessing')}</div>
)}
{!isFinal && (
<div className='shrink-0 mr-3 text-gray-500 text-xs leading-[18px] font-semibold'>{`${t('appLog.agentLogDetail.iteration').toUpperCase()} ${index}`}</div>
)}
<div className='grow h-[1px] bg-gradient-to-r from-[#f3f4f6] to-gray-50'></div>
</div>
<ToolCall
isLLM
isFinal={isFinal}
tokens={iterationInfo.tokens}
observation={iterationInfo.tool_raw.outputs}
finalAnswer={iterationInfo.thought}
toolCall={{
status: 'success',
tool_icon: null,
}}
/>
{iterationInfo.tool_calls.map((toolCall, index) => (
<ToolCall
isLLM={false}
key={index}
toolCall={toolCall}
/>
))}
</div>
)
}
export default Iteration

View File

@@ -0,0 +1,126 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import StatusPanel from '@/app/components/workflow/run/status'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import useTimestamp from '@/hooks/use-timestamp'
type ResultPanelProps = {
status: string
elapsed_time?: number
total_tokens?: number
error?: string
inputs?: any
outputs?: any
created_by?: string
created_at: string
agentMode?: string
tools?: string[]
iterations?: number
}
const ResultPanel: FC<ResultPanelProps> = ({
elapsed_time,
total_tokens,
error,
inputs,
outputs,
created_by,
created_at,
agentMode,
tools,
iterations,
}) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
return (
<div className='bg-white py-2'>
<div className='px-4 py-2'>
<StatusPanel
status='succeeded'
time={elapsed_time}
tokens={total_tokens}
error={error}
/>
</div>
<div className='px-4 py-2 flex flex-col gap-2'>
<CodeEditor
readOnly
title={<div>INPUT</div>}
language={CodeLanguage.json}
value={inputs}
isJSONStringifyBeauty
/>
<CodeEditor
readOnly
title={<div>OUTPUT</div>}
language={CodeLanguage.json}
value={outputs}
isJSONStringifyBeauty
/>
</div>
<div className='px-4 py-2'>
<div className='h-[0.5px] bg-black opacity-5' />
</div>
<div className='px-4 py-2'>
<div className='relative'>
<div className='h-6 leading-6 text-gray-500 text-xs font-medium'>{t('runLog.meta.title')}</div>
<div className='py-1'>
<div className='flex'>
<div className='shrink-0 w-[104px] px-2 py-[5px] text-gray-500 text-xs leading-[18px] truncate'>{t('runLog.meta.status')}</div>
<div className='grow px-2 py-[5px] text-gray-900 text-xs leading-[18px]'>
<span>SUCCESS</span>
</div>
</div>
<div className='flex'>
<div className='shrink-0 w-[104px] px-2 py-[5px] text-gray-500 text-xs leading-[18px] truncate'>{t('runLog.meta.executor')}</div>
<div className='grow px-2 py-[5px] text-gray-900 text-xs leading-[18px]'>
<span>{created_by || 'N/A'}</span>
</div>
</div>
<div className='flex'>
<div className='shrink-0 w-[104px] px-2 py-[5px] text-gray-500 text-xs leading-[18px] truncate'>{t('runLog.meta.startTime')}</div>
<div className='grow px-2 py-[5px] text-gray-900 text-xs leading-[18px]'>
<span>{formatTime(Date.parse(created_at) / 1000, t('appLog.dateTimeFormat') as string)}</span>
</div>
</div>
<div className='flex'>
<div className='shrink-0 w-[104px] px-2 py-[5px] text-gray-500 text-xs leading-[18px] truncate'>{t('runLog.meta.time')}</div>
<div className='grow px-2 py-[5px] text-gray-900 text-xs leading-[18px]'>
<span>{`${elapsed_time?.toFixed(3)}s`}</span>
</div>
</div>
<div className='flex'>
<div className='shrink-0 w-[104px] px-2 py-[5px] text-gray-500 text-xs leading-[18px] truncate'>{t('runLog.meta.tokens')}</div>
<div className='grow px-2 py-[5px] text-gray-900 text-xs leading-[18px]'>
<span>{`${total_tokens || 0} Tokens`}</span>
</div>
</div>
<div className='flex'>
<div className='shrink-0 w-[104px] px-2 py-[5px] text-gray-500 text-xs leading-[18px] truncate'>{t('appLog.agentLogDetail.agentMode')}</div>
<div className='grow px-2 py-[5px] text-gray-900 text-xs leading-[18px]'>
<span>{agentMode === 'function_call' ? t('appDebug.agent.agentModeType.functionCall') : t('appDebug.agent.agentModeType.ReACT')}</span>
</div>
</div>
<div className='flex'>
<div className='shrink-0 w-[104px] px-2 py-[5px] text-gray-500 text-xs leading-[18px] truncate'>{t('appLog.agentLogDetail.toolUsed')}</div>
<div className='grow px-2 py-[5px] text-gray-900 text-xs leading-[18px]'>
<span>{tools?.length ? tools?.join(', ') : 'Null'}</span>
</div>
</div>
<div className='flex'>
<div className='shrink-0 w-[104px] px-2 py-[5px] text-gray-500 text-xs leading-[18px] truncate'>{t('appLog.agentLogDetail.iterations')}</div>
<div className='grow px-2 py-[5px] text-gray-900 text-xs leading-[18px]'>
<span>{iterations}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default ResultPanel

View File

@@ -0,0 +1,142 @@
'use client'
import type { FC } from 'react'
import { useState } from 'react'
import {
RiCheckboxCircleLine,
RiErrorWarningLine,
} from '@remixicon/react'
import { useContext } from 'use-context-selector'
import cn from '@/utils/classnames'
import BlockIcon from '@/app/components/workflow/block-icon'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import type { ToolCall } from '@/models/log'
import { BlockEnum } from '@/app/components/workflow/types'
import I18n from '@/context/i18n'
type Props = {
toolCall: ToolCall
isLLM: boolean
isFinal?: boolean
tokens?: number
observation?: any
finalAnswer?: any
}
const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, observation, finalAnswer }) => {
const [collapseState, setCollapseState] = useState<boolean>(true)
const { locale } = useContext(I18n)
const toolName = isLLM ? 'LLM' : (toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')])
const getTime = (time: number) => {
if (time < 1)
return `${(time * 1000).toFixed(3)} ms`
if (time > 60)
return `${parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s`
return `${time.toFixed(3)} s`
}
const getTokenCount = (tokens: number) => {
if (tokens < 1000)
return tokens
if (tokens >= 1000 && tokens < 1000000)
return `${parseFloat((tokens / 1000).toFixed(3))}K`
if (tokens >= 1000000)
return `${parseFloat((tokens / 1000000).toFixed(3))}M`
}
return (
<div className={cn('py-1')}>
<div className={cn('group transition-all bg-white border border-gray-100 rounded-2xl shadow-xs hover:shadow-md')}>
<div
className={cn(
'flex items-center py-3 pl-[6px] pr-3 cursor-pointer',
!collapseState && '!pb-2',
)}
onClick={() => setCollapseState(!collapseState)}
>
<ChevronRight
className={cn(
'shrink-0 w-3 h-3 mr-1 text-gray-400 transition-all group-hover:text-gray-500',
!collapseState && 'rotate-90',
)}
/>
<BlockIcon className={cn('shrink-0 mr-2')} type={isLLM ? BlockEnum.LLM : BlockEnum.Tool} toolIcon={toolCall.tool_icon} />
<div className={cn(
'grow text-gray-700 text-[13px] leading-[16px] font-semibold truncate',
)} title={toolName}>{toolName}</div>
<div className='shrink-0 text-gray-500 text-xs leading-[18px]'>
{toolCall.time_cost && (
<span>{getTime(toolCall.time_cost || 0)}</span>
)}
{isLLM && (
<span>{`${getTokenCount(tokens || 0)} tokens`}</span>
)}
</div>
{toolCall.status === 'success' && (
<RiCheckboxCircleLine className='shrink-0 ml-2 w-3.5 h-3.5 text-[#12B76A]' />
)}
{toolCall.status === 'error' && (
<RiErrorWarningLine className='shrink-0 ml-2 w-3.5 h-3.5 text-[#F04438]' />
)}
</div>
{!collapseState && (
<div className='pb-2'>
<div className={cn('px-[10px] py-1')}>
{toolCall.status === 'error' && (
<div className='px-3 py-[10px] bg-[#fef3f2] rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] text-xs leading-[18px] text-[#d92d20] shadow-xs'>{toolCall.error}</div>
)}
</div>
{toolCall.tool_input && (
<div className={cn('px-[10px] py-1')}>
<CodeEditor
readOnly
title={<div>INPUT</div>}
language={CodeLanguage.json}
value={toolCall.tool_input}
isJSONStringifyBeauty
/>
</div>
)}
{toolCall.tool_output && (
<div className={cn('px-[10px] py-1')}>
<CodeEditor
readOnly
title={<div>OUTPUT</div>}
language={CodeLanguage.json}
value={toolCall.tool_output}
isJSONStringifyBeauty
/>
</div>
)}
{isLLM && (
<div className={cn('px-[10px] py-1')}>
<CodeEditor
readOnly
title={<div>OBSERVATION</div>}
language={CodeLanguage.json}
value={observation}
isJSONStringifyBeauty
/>
</div>
)}
{isLLM && (
<div className={cn('px-[10px] py-1')}>
<CodeEditor
readOnly
title={<div>{isFinal ? 'FINAL ANSWER' : 'THOUGHT'}</div>}
language={CodeLanguage.json}
value={finalAnswer}
isJSONStringifyBeauty
/>
</div>
)}
</div>
)}
</div>
</div>
)
}
export default ToolCallItem

View File

@@ -0,0 +1,25 @@
'use client'
import type { FC } from 'react'
import Iteration from './iteration'
import type { AgentIteration } from '@/models/log'
type TracingPanelProps = {
list: AgentIteration[]
}
const TracingPanel: FC<TracingPanelProps> = ({ list }) => {
return (
<div className='bg-gray-50'>
{list.map((iteration, index) => (
<Iteration
key={index}
index={index + 1}
isFinal={index + 1 === list.length}
iterationInfo={iteration}
/>
))}
</div>
)
}
export default TracingPanel

View File

@@ -0,0 +1,47 @@
'use client'
import type { FC } from 'react'
import { init } from 'emoji-mart'
import data from '@emoji-mart/data'
import classNames from '@/utils/classnames'
import type { AppIconType } from '@/types/app'
init({ data })
export type AnswerIconProps = {
iconType?: AppIconType | null
icon?: string | null
background?: string | null
imageUrl?: string | null
}
const AnswerIcon: FC<AnswerIconProps> = ({
iconType,
icon,
background,
imageUrl,
}) => {
const wrapperClassName = classNames(
'flex',
'items-center',
'justify-center',
'w-full',
'h-full',
'rounded-full',
'border-[0.5px]',
'border-black/5',
'text-xl',
)
const isValidImageIcon = iconType === 'image' && imageUrl
return <div
className={wrapperClassName}
style={{ background: background || '#D5F5F6' }}
>
{isValidImageIcon
? <img src={imageUrl} className="w-full h-full rounded-full" alt="answer icon" />
: (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />
}
</div>
}
export default AnswerIcon

View File

@@ -0,0 +1,124 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import { createRef, useEffect, useState } from 'react'
import Cropper, { type Area, type CropperProps } from 'react-easy-crop'
import classNames from 'classnames'
import { ImagePlus } from '../icons/src/vender/line/images'
import { useDraggableUploader } from './hooks'
import { checkIsAnimatedImage } from './utils'
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
export type OnImageInput = {
(isCropped: true, tempUrl: string, croppedAreaPixels: Area, fileName: string): void
(isCropped: false, file: File): void
}
type UploaderProps = {
className?: string
cropShape?: CropperProps['cropShape']
onImageInput?: OnImageInput
}
const ImageInput: FC<UploaderProps> = ({
className,
cropShape,
onImageInput,
}) => {
const [inputImage, setInputImage] = useState<{ file: File; url: string }>()
const [isAnimatedImage, setIsAnimatedImage] = useState<boolean>(false)
useEffect(() => {
return () => {
if (inputImage)
URL.revokeObjectURL(inputImage.url)
}
}, [inputImage])
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const onCropComplete = async (_: Area, croppedAreaPixels: Area) => {
if (!inputImage)
return
onImageInput?.(true, inputImage.url, croppedAreaPixels, inputImage.file.name)
}
const handleLocalFileInput = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setInputImage({ file, url: URL.createObjectURL(file) })
checkIsAnimatedImage(file).then((isAnimatedImage) => {
setIsAnimatedImage(!!isAnimatedImage)
if (isAnimatedImage)
onImageInput?.(false, file)
})
}
}
const {
isDragActive,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
} = useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) }))
const inputRef = createRef<HTMLInputElement>()
const handleShowImage = () => {
if (isAnimatedImage) {
return (
<img src={inputImage?.url} alt='' />
)
}
return (
<Cropper
image={inputImage?.url}
crop={crop}
zoom={zoom}
aspect={1}
cropShape={cropShape}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
)
}
return (
<div className={classNames(className, 'w-full px-3 py-1.5')}>
<div
className={classNames(
isDragActive && 'border-primary-600',
'relative aspect-square bg-gray-50 border-[1.5px] border-gray-200 border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{
!inputImage
? <>
<ImagePlus className="w-[30px] h-[30px] mb-3 pointer-events-none" />
<div className="text-sm font-medium mb-[2px]">
<span className="pointer-events-none">Drop your image here, or&nbsp;</span>
<button className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>browse</button>
<input
ref={inputRef} type="file" className="hidden"
onClick={e => ((e.target as HTMLInputElement).value = '')}
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
onChange={handleLocalFileInput}
/>
</div>
<div className="text-xs pointer-events-none">Supports PNG, JPG, JPEG, WEBP and GIF</div>
</>
: handleShowImage()
}
</div>
</div>
)
}
export default ImageInput

View File

@@ -0,0 +1,43 @@
import { useCallback, useState } from 'react'
export const useDraggableUploader = <T extends HTMLElement>(setImageFn: (file: File) => void) => {
const [isDragActive, setIsDragActive] = useState(false)
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(true)
}, [])
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
const file = e.dataTransfer.files[0]
if (!file)
return
setImageFn(file)
}, [setImageFn])
return {
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
isDragActive,
}
}

View File

@@ -0,0 +1,149 @@
import type { FC } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Area } from 'react-easy-crop'
import Modal from '../modal'
import Divider from '../divider'
import Button from '../button'
import { ImagePlus } from '../icons/src/vender/line/images'
import { useLocalFileUploader } from '../image-uploader/hooks'
import EmojiPickerInner from '../emoji-picker/Inner'
import type { OnImageInput } from './ImageInput'
import ImageInput from './ImageInput'
import s from './style.module.css'
import getCroppedImg from './utils'
import type { AppIconType, ImageFile } from '@/types/app'
import cn from '@/utils/classnames'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
export type AppIconEmojiSelection = {
type: 'emoji'
icon: string
background: string
}
export type AppIconImageSelection = {
type: 'image'
fileId: string
url: string
}
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
type AppIconPickerProps = {
onSelect?: (payload: AppIconSelection) => void
onClose?: () => void
className?: string
}
const AppIconPicker: FC<AppIconPickerProps> = ({
onSelect,
onClose,
className,
}) => {
const { t } = useTranslation()
const tabs = [
{ key: 'emoji', label: t('app.iconPicker.emoji'), icon: <span className="text-lg">🤖</span> },
{ key: 'image', label: t('app.iconPicker.image'), icon: <ImagePlus /> },
]
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
const [emoji, setEmoji] = useState<{ emoji: string; background: string }>()
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
setEmoji({ emoji, background })
}, [setEmoji])
const [uploading, setUploading] = useState<boolean>()
const { handleLocalFileUpload } = useLocalFileUploader({
limit: 3,
disabled: false,
onUpload: (imageFile: ImageFile) => {
if (imageFile.fileId) {
setUploading(false)
onSelect?.({
type: 'image',
fileId: imageFile.fileId,
url: imageFile.url,
})
}
},
})
type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string }
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
const handleImageInput: OnImageInput = async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => {
setInputImageInfo(
isCropped
? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! }
: { file: fileOrTempUrl as File },
)
}
const handleSelect = async () => {
if (activeTab === 'emoji') {
if (emoji) {
onSelect?.({
type: 'emoji',
icon: emoji.emoji,
background: emoji.background,
})
}
}
else {
if (!inputImageInfo)
return
setUploading(true)
if ('file' in inputImageInfo) {
handleLocalFileUpload(inputImageInfo.file)
return
}
const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName)
const file = new File([blob], inputImageInfo.fileName, { type: blob.type })
handleLocalFileUpload(file)
}
}
return <Modal
onClose={() => { }}
isShow
closable={false}
wrapperClassName={className}
className={cn(s.container, '!w-[362px] !p-0')}
>
{!DISABLE_UPLOAD_IMAGE_AS_ICON && <div className="p-2 pb-0 w-full">
<div className='p-1 flex items-center justify-center gap-2 bg-background-body rounded-xl'>
{tabs.map(tab => (
<button
key={tab.key}
className={`
p-2 flex-1 flex justify-center items-center h-8 rounded-xl text-sm shrink-0 font-medium
${activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active shadow-md'}
`}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon} &nbsp; {tab.label}
</button>
))}
</div>
</div>}
<EmojiPickerInner className={cn(activeTab === 'emoji' ? 'block' : 'hidden', 'pt-2')} onSelect={handleSelectEmoji} />
<ImageInput className={activeTab === 'image' ? 'block' : 'hidden'} onImageInput={handleImageInput} />
<Divider className='m-0' />
<div className='w-full flex items-center justify-center p-3 gap-2'>
<Button className='w-full' onClick={() => onClose?.()}>
{t('app.iconPicker.cancel')}
</Button>
<Button variant="primary" className='w-full' disabled={uploading} loading={uploading} onClick={handleSelect}>
{t('app.iconPicker.ok')}
</Button>
</div>
</Modal>
}
export default AppIconPicker

View File

@@ -0,0 +1,12 @@
.container {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 362px;
max-height: 552px;
border: 0.5px solid #EAECF0;
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
border-radius: 12px;
background: #fff;
}

View File

@@ -0,0 +1,166 @@
export const createImage = (url: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', error => reject(error))
image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox
image.src = url
})
export function getRadianAngle(degreeValue: number) {
return (degreeValue * Math.PI) / 180
}
export function getMimeType(fileName: string): string {
const extension = fileName.split('.').pop()?.toLowerCase()
switch (extension) {
case 'png':
return 'image/png'
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
default:
return 'image/jpeg'
}
}
/**
* Returns the new bounding area of a rotated rectangle.
*/
export function rotateSize(width: number, height: number, rotation: number) {
const rotRad = getRadianAngle(rotation)
return {
width:
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
height:
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
}
}
/**
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
*/
export default async function getCroppedImg(
imageSrc: string,
pixelCrop: { x: number; y: number; width: number; height: number },
fileName: string,
rotation = 0,
flip = { horizontal: false, vertical: false },
): Promise<Blob> {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const mimeType = getMimeType(fileName)
if (!ctx)
throw new Error('Could not create a canvas context')
const rotRad = getRadianAngle(rotation)
// calculate bounding box of the rotated image
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
image.width,
image.height,
rotation,
)
// set canvas size to match the bounding box
canvas.width = bBoxWidth
canvas.height = bBoxHeight
// translate canvas context to a central location to allow rotating and flipping around the center
ctx.translate(bBoxWidth / 2, bBoxHeight / 2)
ctx.rotate(rotRad)
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1)
ctx.translate(-image.width / 2, -image.height / 2)
// draw rotated image
ctx.drawImage(image, 0, 0)
const croppedCanvas = document.createElement('canvas')
const croppedCtx = croppedCanvas.getContext('2d')
if (!croppedCtx)
throw new Error('Could not create a canvas context')
// Set the size of the cropped canvas
croppedCanvas.width = pixelCrop.width
croppedCanvas.height = pixelCrop.height
// Draw the cropped image onto the new canvas
croppedCtx.drawImage(
canvas,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((file) => {
if (file)
resolve(file)
else
reject(new Error('Could not create a blob'))
}, mimeType)
})
}
export function checkIsAnimatedImage(file: File): Promise<boolean> {
return new Promise((resolve, reject) => {
const fileReader = new FileReader()
fileReader.onload = function (e) {
const arr = new Uint8Array(e.target?.result as ArrayBuffer)
// Check file extension
const fileName = file.name.toLowerCase()
if (fileName.endsWith('.gif')) {
// If file is a GIF, assume it's animated
resolve(true)
}
// Check for WebP signature (RIFF and WEBP)
else if (isWebP(arr)) {
resolve(checkWebPAnimation(arr)) // Check if it's animated
}
else {
resolve(false) // Not a GIF or WebP
}
}
fileReader.onerror = function (err) {
reject(err) // Reject the promise on error
}
// Read the file as an array buffer
fileReader.readAsArrayBuffer(file)
})
}
// Function to check for WebP signature
function isWebP(arr: Uint8Array) {
return (
arr[0] === 0x52 && arr[1] === 0x49 && arr[2] === 0x46 && arr[3] === 0x46
&& arr[8] === 0x57 && arr[9] === 0x45 && arr[10] === 0x42 && arr[11] === 0x50
) // "WEBP"
}
// Function to check if the WebP is animated (contains ANIM chunk)
function checkWebPAnimation(arr: Uint8Array) {
// Search for the ANIM chunk in WebP to determine if it's animated
for (let i = 12; i < arr.length - 4; i++) {
if (arr[i] === 0x41 && arr[i + 1] === 0x4E && arr[i + 2] === 0x49 && arr[i + 3] === 0x4D)
return true // Found animation
}
return false // No animation chunk found
}

View File

@@ -0,0 +1,71 @@
'use client'
import type { FC } from 'react'
import { init } from 'emoji-mart'
import data from '@emoji-mart/data'
import { cva } from 'class-variance-authority'
import type { AppIconType } from '@/types/app'
import classNames from '@/utils/classnames'
init({ data })
export type AppIconProps = {
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' | 'xl' | 'xxl'
rounded?: boolean
iconType?: AppIconType | null
icon?: string
background?: string | null
imageUrl?: string | null
className?: string
innerIcon?: React.ReactNode
onClick?: () => void
}
const appIconVariants = cva(
'flex items-center justify-center relative text-lg rounded-lg grow-0 shrink-0 overflow-hidden leading-none',
{
variants: {
size: {
xs: 'w-4 h-4 text-xs',
tiny: 'w-6 h-6 text-base',
small: 'w-8 h-8 text-xl',
medium: 'w-9 h-9 text-[22px]',
large: 'w-10 h-10 text-[24px]',
xl: 'w-12 h-12 text-[28px]',
xxl: 'w-14 h-14 text-[32px]',
},
rounded: {
true: 'rounded-full',
},
},
defaultVariants: {
size: 'medium',
rounded: false,
},
})
const AppIcon: FC<AppIconProps> = ({
size = 'medium',
rounded = false,
iconType,
icon,
background,
imageUrl,
className,
innerIcon,
onClick,
}) => {
const isValidImageIcon = iconType === 'image' && imageUrl
return <span
className={classNames(appIconVariants({ size, rounded }), className)}
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
onClick={onClick}
>
{isValidImageIcon
// eslint-disable-next-line @next/next/no-img-element
? <img src={imageUrl} className="w-full h-full" alt="app icon" />
: (innerIcon || ((icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />))
}
</span>
}
export default AppIcon

View File

@@ -0,0 +1,29 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
type IAppUnavailableProps = {
code?: number
isUnknownReason?: boolean
unknownReason?: string
}
const AppUnavailable: FC<IAppUnavailableProps> = ({
code = 404,
isUnknownReason,
unknownReason,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center justify-center w-screen h-screen'>
<h1 className='mr-5 h-[50px] leading-[50px] pr-5 text-[24px] font-medium'
style={{
borderRight: '1px solid rgba(0,0,0,.3)',
}}>{code}</h1>
<div className='text-sm'>{unknownReason || (isUnknownReason ? t('share.common.appUnknownError') : t('share.common.appUnavailable'))}</div>
</div>
)
}
export default React.memo(AppUnavailable)

View File

@@ -0,0 +1,53 @@
import AudioPlayer from '@/app/components/base/audio-btn/audio'
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface AudioPlayerManager {
instance: AudioPlayerManager
}
}
export class AudioPlayerManager {
private static instance: AudioPlayerManager
private audioPlayers: AudioPlayer | null = null
private msgId: string | undefined
private constructor() {
}
public static getInstance(): AudioPlayerManager {
if (!AudioPlayerManager.instance) {
AudioPlayerManager.instance = new AudioPlayerManager()
this.instance = AudioPlayerManager.instance
}
return AudioPlayerManager.instance
}
public getAudioPlayer(url: string, isPublic: boolean, id: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => {}) | null): AudioPlayer {
if (this.msgId && this.msgId === id && this.audioPlayers) {
this.audioPlayers.setCallback(callback)
return this.audioPlayers
}
else {
if (this.audioPlayers) {
try {
this.audioPlayers.pauseAudio()
this.audioPlayers.cacheBuffers = []
this.audioPlayers.sourceBuffer?.abort()
}
catch (e) {
}
}
this.msgId = id
this.audioPlayers = new AudioPlayer(url, isPublic, id, msgContent, voice, callback)
return this.audioPlayers
}
}
public resetMsgId(msgId: string) {
this.msgId = msgId
this.audioPlayers?.resetMsgId(msgId)
}
}

View File

@@ -0,0 +1,251 @@
import Toast from '@/app/components/base/toast'
import { textToAudioStream } from '@/service/share'
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
ManagedMediaSource: any
}
}
export default class AudioPlayer {
mediaSource: MediaSource | null
audio: HTMLAudioElement
audioContext: AudioContext
sourceBuffer?: any
cacheBuffers: ArrayBuffer[] = []
pauseTimer: number | null = null
msgId: string | undefined
msgContent: string | null | undefined = null
voice: string | undefined = undefined
isLoadData = false
url: string
isPublic: boolean
callback: ((event: string) => {}) | null
constructor(streamUrl: string, isPublic: boolean, msgId: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => {}) | null) {
this.audioContext = new AudioContext()
this.msgId = msgId
this.msgContent = msgContent
this.url = streamUrl
this.isPublic = isPublic
this.voice = voice
this.callback = callback
// Compatible with iphone ios17 ManagedMediaSource
const MediaSource = window.ManagedMediaSource || window.MediaSource
if (!MediaSource) {
Toast.notify({
message: 'Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.',
type: 'error',
})
}
this.mediaSource = MediaSource ? new MediaSource() : null
this.audio = new Audio()
this.setCallback(callback)
if (!window.MediaSource) { // if use ManagedMediaSource
this.audio.disableRemotePlayback = true
this.audio.controls = true
}
this.audio.src = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
this.audio.autoplay = true
const source = this.audioContext.createMediaElementSource(this.audio)
source.connect(this.audioContext.destination)
this.listenMediaSource('audio/mpeg')
}
public resetMsgId(msgId: string) {
this.msgId = msgId
}
private listenMediaSource(contentType: string) {
this.mediaSource?.addEventListener('sourceopen', () => {
if (this.sourceBuffer)
return
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
})
}
public setCallback(callback: ((event: string) => {}) | null) {
this.callback = callback
if (callback) {
this.audio.addEventListener('ended', () => {
callback('ended')
}, false)
this.audio.addEventListener('paused', () => {
callback('paused')
}, true)
this.audio.addEventListener('loaded', () => {
callback('loaded')
}, true)
this.audio.addEventListener('play', () => {
callback('play')
}, true)
this.audio.addEventListener('timeupdate', () => {
callback('timeupdate')
}, true)
this.audio.addEventListener('loadeddate', () => {
callback('loadeddate')
}, true)
this.audio.addEventListener('canplay', () => {
callback('canplay')
}, true)
this.audio.addEventListener('error', () => {
callback('error')
}, true)
}
}
private async loadAudio() {
try {
const audioResponse: any = await textToAudioStream(this.url, this.isPublic, { content_type: 'audio/mpeg' }, {
message_id: this.msgId,
streaming: true,
voice: this.voice,
text: this.msgContent,
})
if (audioResponse.status !== 200) {
this.isLoadData = false
if (this.callback)
this.callback('error')
}
const reader = audioResponse.body.getReader()
while (true) {
const { value, done } = await reader.read()
if (done) {
this.receiveAudioData(value)
break
}
this.receiveAudioData(value)
}
}
catch (error) {
this.isLoadData = false
this.callback && this.callback('error')
}
}
// play audio
public playAudio() {
if (this.isLoadData) {
if (this.audioContext.state === 'suspended') {
this.audioContext.resume().then((_) => {
this.audio.play()
this.callback && this.callback('play')
})
}
else if (this.audio.ended) {
this.audio.play()
this.callback && this.callback('play')
}
if (this.callback)
this.callback('play')
}
else {
this.isLoadData = true
this.loadAudio()
}
}
private theEndOfStream() {
const endTimer = setInterval(() => {
if (!this.sourceBuffer?.updating) {
this.mediaSource?.endOfStream()
clearInterval(endTimer)
}
}, 10)
}
private finishStream() {
const timer = setInterval(() => {
if (!this.cacheBuffers.length) {
this.theEndOfStream()
clearInterval(timer)
}
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
const arrayBuffer = this.cacheBuffers.shift()!
this.sourceBuffer?.appendBuffer(arrayBuffer)
}
}, 10)
}
public async playAudioWithAudio(audio: string, play = true) {
if (!audio || !audio.length) {
this.finishStream()
return
}
const audioContent = Buffer.from(audio, 'base64')
this.receiveAudioData(new Uint8Array(audioContent))
if (play) {
this.isLoadData = true
if (this.audio.paused) {
this.audioContext.resume().then((_) => {
this.audio.play()
this.callback && this.callback('play')
})
}
else if (this.audio.ended) {
this.audio.play()
this.callback && this.callback('play')
}
else if (this.audio.played) { /* empty */ }
else {
this.audio.play()
this.callback && this.callback('play')
}
}
}
public pauseAudio() {
this.callback && this.callback('paused')
this.audio.pause()
this.audioContext.suspend()
}
private cancer() {
}
private receiveAudioData(unit8Array: Uint8Array) {
if (!unit8Array) {
this.finishStream()
return
}
const audioData = this.byteArrayToArrayBuffer(unit8Array)
if (!audioData.byteLength) {
if (this.mediaSource?.readyState === 'open')
this.finishStream()
return
}
if (this.sourceBuffer?.updating) {
this.cacheBuffers.push(audioData)
}
else {
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
this.cacheBuffers.push(audioData)
const cacheBuffer = this.cacheBuffers.shift()!
this.sourceBuffer?.appendBuffer(cacheBuffer)
}
else {
this.sourceBuffer?.appendBuffer(audioData)
}
}
}
private byteArrayToArrayBuffer(byteArray: Uint8Array): ArrayBuffer {
const arrayBuffer = new ArrayBuffer(byteArray.length)
const uint8Array = new Uint8Array(arrayBuffer)
uint8Array.set(byteArray)
return arrayBuffer
}
}

View File

@@ -0,0 +1,110 @@
'use client'
import { useState } from 'react'
import { t } from 'i18next'
import { useParams, usePathname } from 'next/navigation'
import s from './style.module.css'
import Tooltip from '@/app/components/base/tooltip'
import Loading from '@/app/components/base/loading'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
type AudioBtnProps = {
id?: string
voice?: string
value?: string
className?: string
isAudition?: boolean
noCache?: boolean
}
type AudioState = 'initial' | 'loading' | 'playing' | 'paused' | 'ended'
const AudioBtn = ({
id,
voice,
value,
className,
isAudition,
}: AudioBtnProps) => {
const [audioState, setAudioState] = useState<AudioState>('initial')
const params = useParams()
const pathname = usePathname()
const audio_finished_call = (event: string): any => {
switch (event) {
case 'ended':
setAudioState('ended')
break
case 'paused':
setAudioState('ended')
break
case 'loaded':
setAudioState('loading')
break
case 'play':
setAudioState('playing')
break
case 'error':
setAudioState('ended')
break
}
}
let url = ''
let isPublic = false
if (params.token) {
url = '/text-to-audio'
isPublic = true
}
else if (params.appId) {
if (pathname.search('explore/installed') > -1)
url = `/installed-apps/${params.appId}/text-to-audio`
else
url = `/apps/${params.appId}/text-to-audio`
}
const handleToggle = async () => {
if (audioState === 'playing' || audioState === 'loading') {
setTimeout(() => setAudioState('paused'), 1)
AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).pauseAudio()
}
else {
setTimeout(() => setAudioState('loading'), 1)
AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).playAudio()
}
}
const tooltipContent = {
initial: t('appApi.play'),
ended: t('appApi.play'),
paused: t('appApi.pause'),
playing: t('appApi.playing'),
loading: t('appApi.loading'),
}[audioState]
return (
<div className={`inline-flex items-center justify-center ${(audioState === 'loading' || audioState === 'playing') ? 'mr-1' : className}`}>
<Tooltip
popupContent={tooltipContent}
>
<button
disabled={audioState === 'loading'}
className={`box-border w-6 h-6 flex items-center justify-center cursor-pointer ${isAudition ? 'p-0.5' : 'p-0 rounded-md bg-white'}`}
onClick={handleToggle}
>
{audioState === 'loading'
? (
<div className='w-full h-full rounded-md flex items-center justify-center'>
<Loading />
</div>
)
: (
<div className={`w-full h-full rounded-md flex items-center justify-center ${!isAudition ? 'hover:bg-gray-50' : 'hover:bg-gray-50'}`}>
<div className={`w-4 h-4 ${(audioState === 'playing') ? s.pauseIcon : s.playIcon}`}></div>
</div>
)}
</button>
</Tooltip>
</div>
)
}
export default AudioBtn

View File

@@ -0,0 +1,10 @@
.playIcon {
background-image: url(~@/app/components/develop/secret-key/assets/play.svg);
background-position: center;
background-repeat: no-repeat;
}
.pauseIcon {
background-image: url(~@/app/components/develop/secret-key/assets/pause.svg);
background-position: center;
background-repeat: no-repeat;
}

View File

@@ -0,0 +1,119 @@
.audioPlayer {
display: flex;
flex-direction: row;
align-items: center;
background-color: #ffffff;
border-radius: 10px;
padding: 8px;
min-width: 240px;
max-width: 420px;
max-height: 40px;
backdrop-filter: blur(5px);
border: 1px solid rgba(16, 24, 40, 0.08);
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.05);
gap: 8px;
}
.playButton {
display: inline-flex;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #296DFF;
color: white;
border: none;
cursor: pointer;
align-items: center;
justify-content: center;
transition: background-color 0.1s;
flex-shrink: 0;
}
.playButton:hover {
background-color: #3367d6;
}
.playButton:disabled {
background-color: #bdbdbf;
}
.audioControls {
flex-grow: 1;
}
.progressBarContainer {
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.waveform {
position: relative;
display: flex;
cursor: pointer;
height: 24px;
width: 100%;
flex-grow: 1;
align-items: center;
justify-content: center;
}
.progressBar {
position: absolute;
top: 0;
left: 0;
opacity: 0.5;
border-radius: 2px;
flex: none;
order: 55;
flex-grow: 0;
height: 100%;
background-color: rgba(66, 133, 244, 0.3);
pointer-events: none;
}
.timeDisplay {
/* position: absolute; */
color: #296DFF;
border-radius: 2px;
order: 0;
height: 100%;
width: 50px;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* .currentTime {
position: absolute;
bottom: calc(100% + 5px);
transform: translateX(-50%);
background-color: rgba(255,255,255,.8);
padding: 2px 4px;
border-radius:10px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.08);
} */
.duration {
background-color: rgba(255, 255, 255, 0.8);
padding: 2px 4px;
border-radius: 10px;
}
.source_unavailable {
border: none;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: absolute;
color: #bdbdbf;
}
.playButton svg path,
.playButton svg rect{
fill:currentColor;
}

View File

@@ -0,0 +1,320 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { t } from 'i18next'
import styles from './AudioPlayer.module.css'
import Toast from '@/app/components/base/toast'
type AudioPlayerProps = {
src: string
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({ src }) => {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [waveformData, setWaveformData] = useState<number[]>([])
const [bufferedTime, setBufferedTime] = useState(0)
const audioRef = useRef<HTMLAudioElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const [hasStartedPlaying, setHasStartedPlaying] = useState(false)
const [hoverTime, setHoverTime] = useState(0)
const [isAudioAvailable, setIsAudioAvailable] = useState(true)
useEffect(() => {
const audio = audioRef.current
if (!audio)
return
const handleError = () => {
setIsAudioAvailable(false)
}
const setAudioData = () => {
setDuration(audio.duration)
}
const setAudioTime = () => {
setCurrentTime(audio.currentTime)
}
const handleProgress = () => {
if (audio.buffered.length > 0)
setBufferedTime(audio.buffered.end(audio.buffered.length - 1))
}
const handleEnded = () => {
setIsPlaying(false)
}
audio.addEventListener('loadedmetadata', setAudioData)
audio.addEventListener('timeupdate', setAudioTime)
audio.addEventListener('progress', handleProgress)
audio.addEventListener('ended', handleEnded)
audio.addEventListener('error', handleError)
// Preload audio metadata
audio.load()
// Delayed generation of waveform data
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const timer = setTimeout(() => generateWaveformData(src), 1000)
return () => {
audio.removeEventListener('loadedmetadata', setAudioData)
audio.removeEventListener('timeupdate', setAudioTime)
audio.removeEventListener('progress', handleProgress)
audio.removeEventListener('ended', handleEnded)
audio.removeEventListener('error', handleError)
clearTimeout(timer)
}
}, [src])
const generateWaveformData = async (audioSrc: string) => {
if (!window.AudioContext && !(window as any).webkitAudioContext) {
setIsAudioAvailable(false)
Toast.notify({
type: 'error',
message: 'Web Audio API is not supported in this browser',
})
return null
}
const url = new URL(src)
const isHttp = url.protocol === 'http:' || url.protocol === 'https:'
if (!isHttp) {
setIsAudioAvailable(false)
return null
}
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
const samples = 70
try {
const response = await fetch(audioSrc, { mode: 'cors' })
if (!response || !response.ok) {
setIsAudioAvailable(false)
return null
}
const arrayBuffer = await response.arrayBuffer()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
const channelData = audioBuffer.getChannelData(0)
const blockSize = Math.floor(channelData.length / samples)
const waveformData: number[] = []
for (let i = 0; i < samples; i++) {
let sum = 0
for (let j = 0; j < blockSize; j++)
sum += Math.abs(channelData[i * blockSize + j])
// Apply nonlinear scaling to enhance small amplitudes
waveformData.push((sum / blockSize) * 5)
}
// Normalized waveform data
const maxAmplitude = Math.max(...waveformData)
const normalizedWaveform = waveformData.map(amp => amp / maxAmplitude)
setWaveformData(normalizedWaveform)
setIsAudioAvailable(true)
}
catch (error) {
const waveform: number[] = []
let prevValue = Math.random()
for (let i = 0; i < samples; i++) {
const targetValue = Math.random()
const interpolatedValue = prevValue + (targetValue - prevValue) * 0.3
waveform.push(interpolatedValue)
prevValue = interpolatedValue
}
const maxAmplitude = Math.max(...waveform)
const randomWaveform = waveform.map(amp => amp / maxAmplitude)
setWaveformData(randomWaveform)
setIsAudioAvailable(true)
}
finally {
await audioContext.close()
}
}
const togglePlay = useCallback(() => {
const audio = audioRef.current
if (audio && isAudioAvailable) {
if (isPlaying) {
setHasStartedPlaying(false)
audio.pause()
}
else {
setHasStartedPlaying(true)
audio.play().catch(error => console.error('Error playing audio:', error))
}
setIsPlaying(!isPlaying)
}
else {
Toast.notify({
type: 'error',
message: 'Audio element not found',
})
setIsAudioAvailable(false)
}
}, [isAudioAvailable, isPlaying])
const handleCanvasInteraction = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault()
const getClientX = (event: React.MouseEvent | React.TouchEvent): number => {
if ('touches' in event)
return event.touches[0].clientX
return event.clientX
}
const updateProgress = (clientX: number) => {
const canvas = canvasRef.current
const audio = audioRef.current
if (!canvas || !audio)
return
const rect = canvas.getBoundingClientRect()
const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width
const newTime = percent * duration
// Removes the buffer check, allowing drag to any location
audio.currentTime = newTime
setCurrentTime(newTime)
if (!isPlaying) {
setIsPlaying(true)
audio.play().catch((error) => {
Toast.notify({
type: 'error',
message: `Error playing audio: ${error}`,
})
setIsPlaying(false)
})
}
}
updateProgress(getClientX(e))
}, [duration, isPlaying])
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const drawWaveform = useCallback(() => {
const canvas = canvasRef.current
if (!canvas)
return
const ctx = canvas.getContext('2d')
if (!ctx)
return
const width = canvas.width
const height = canvas.height
const data = waveformData
ctx.clearRect(0, 0, width, height)
const barWidth = width / data.length
const playedWidth = (currentTime / duration) * width
const cornerRadius = 2
// Draw waveform bars
data.forEach((value, index) => {
let color
if (index * barWidth <= playedWidth)
color = '#296DFF'
else if ((index * barWidth / width) * duration <= hoverTime)
color = 'rgba(21,90,239,.40)'
else
color = 'rgba(21,90,239,.20)'
const barHeight = value * height
const rectX = index * barWidth
const rectY = (height - barHeight) / 2
const rectWidth = barWidth * 0.5
const rectHeight = barHeight
ctx.lineWidth = 1
ctx.fillStyle = color
if (ctx.roundRect) {
ctx.beginPath()
ctx.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius)
ctx.fill()
}
else {
ctx.fillRect(rectX, rectY, rectWidth, rectHeight)
}
})
}, [currentTime, duration, hoverTime, waveformData])
useEffect(() => {
drawWaveform()
}, [drawWaveform, bufferedTime, hasStartedPlaying])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const canvas = canvasRef.current
const audio = audioRef.current
if (!canvas || !audio)
return
const rect = canvas.getBoundingClientRect()
const percent = Math.min(Math.max(0, e.clientX - rect.left), rect.width) / rect.width
const time = percent * duration
// Check if the hovered position is within a buffered range before updating hoverTime
for (let i = 0; i < audio.buffered.length; i++) {
if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
setHoverTime(time)
break
}
}
}, [duration])
return (
<div className={styles.audioPlayer}>
<audio ref={audioRef} src={src} preload="auto"/>
<button className={styles.playButton} onClick={togglePlay} disabled={!isAudioAvailable}>
{isPlaying
? (
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="7" y="6" width="3" height="12" rx="1.5" ry="1.5"/>
<rect x="15" y="6" width="3" height="12" rx="1.5" ry="1.5"/>
</svg>
)
: (
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M8 5v14l11-7z" fill="currentColor"/>
</svg>
)}
</button>
<div className={isAudioAvailable ? styles.audioControls : styles.audioControls_disabled} hidden={!isAudioAvailable}>
<div className={styles.progressBarContainer}>
<canvas
ref={canvasRef}
className={styles.waveform}
onClick={handleCanvasInteraction}
onMouseMove={handleMouseMove}
onMouseDown={handleCanvasInteraction}
/>
{/* <div className={styles.currentTime} style={{ left: `${(currentTime / duration) * 81}%`, bottom: '29px' }}>
{formatTime(currentTime)}
</div> */}
<div className={styles.timeDisplay}>
<span className={styles.duration}>{formatTime(duration)}</span>
</div>
</div>
</div>
<div className={styles.source_unavailable} hidden={isAudioAvailable}>{t('common.operation.audioSourceUnavailable')}</div>
</div>
)
}
export default AudioPlayer

View File

@@ -0,0 +1,12 @@
import React from 'react'
import AudioPlayer from './AudioPlayer'
type Props = {
srcs: string[]
}
const AudioGallery: React.FC<Props> = ({ srcs }) => {
return (<><br/>{srcs.map((src, index) => (<AudioPlayer key={`audio_${index}`} src={src}/>))}</>)
}
export default React.memo(AudioGallery)

View File

@@ -0,0 +1,54 @@
import { forwardRef, useEffect, useRef } from 'react'
import cn from '@/utils/classnames'
type AutoHeightTextareaProps =
& React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>
& { outerClassName?: string }
const AutoHeightTextarea = forwardRef<HTMLTextAreaElement, AutoHeightTextareaProps>(
(
{
outerClassName,
value,
className,
placeholder,
autoFocus,
disabled,
...rest
},
outRef,
) => {
const innerRef = useRef<HTMLTextAreaElement>(null)
const ref = outRef || innerRef
useEffect(() => {
if (autoFocus && !disabled && value) {
if (typeof ref !== 'function') {
ref.current?.setSelectionRange(`${value}`.length, `${value}`.length)
ref.current?.focus()
}
}
}, [autoFocus, disabled, ref])
return (
<div className={outerClassName}>
<div className='relative'>
<div className={cn(className, 'invisible whitespace-pre-wrap break-all')}>
{!value ? placeholder : `${value}`.replace(/\n$/, '\n ')}
</div>
<textarea
ref={ref}
placeholder={placeholder}
className={cn(className, 'disabled:bg-transparent absolute inset-0 outline-none border-none appearance-none resize-none w-full h-full')}
value={value}
disabled={disabled}
{...rest}
/>
</div>
</div>
)
},
)
AutoHeightTextarea.displayName = 'AutoHeightTextarea'
export default AutoHeightTextarea

View File

@@ -0,0 +1,84 @@
import { forwardRef, useEffect, useRef } from 'react'
import cn from '@/utils/classnames'
import { sleep } from '@/utils'
type IProps = {
placeholder?: string
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
className?: string
wrapperClassName?: string
minHeight?: number
maxHeight?: number
autoFocus?: boolean
controlFocus?: number
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
}
const AutoHeightTextarea = forwardRef(
(
{ value, onChange, placeholder, className, wrapperClassName, minHeight = 36, maxHeight = 96, autoFocus, controlFocus, onKeyDown, onKeyUp }: IProps,
outerRef: any,
) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const ref = outerRef || useRef<HTMLTextAreaElement>(null)
const doFocus = () => {
if (ref.current) {
ref.current.setSelectionRange(value.length, value.length)
ref.current.focus()
return true
}
return false
}
const focus = async () => {
if (!doFocus()) {
let hasFocus = false
await sleep(100)
hasFocus = doFocus()
if (!hasFocus)
focus()
}
}
useEffect(() => {
if (autoFocus)
focus()
}, [])
useEffect(() => {
if (controlFocus)
focus()
}, [controlFocus])
return (
<div className={`relative ${wrapperClassName}`}>
<div className={cn(className, 'invisible whitespace-pre-wrap break-all overflow-y-auto')} style={{
minHeight,
maxHeight,
paddingRight: (value && value.trim().length > 10000) ? 140 : 130,
}}>
{!value ? placeholder : value.replace(/\n$/, '\n ')}
</div>
<textarea
ref={ref}
autoFocus={autoFocus}
className={cn(className, 'absolute inset-0 resize-none overflow-auto')}
style={{
paddingRight: (value && value.trim().length > 10000) ? 140 : 130,
}}
placeholder={placeholder}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
value={value}
/>
</div>
)
},
)
AutoHeightTextarea.displayName = 'AutoHeightTextarea'
export default AutoHeightTextarea

View File

@@ -0,0 +1,54 @@
'use client'
import { useState } from 'react'
import cn from '@/utils/classnames'
export type AvatarProps = {
name: string
avatar: string | null
size?: number
className?: string
textClassName?: string
}
const Avatar = ({
name,
avatar,
size = 30,
className,
textClassName,
}: AvatarProps) => {
const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600'
const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
const [imgError, setImgError] = useState(false)
const handleError = () => {
setImgError(true)
}
if (avatar && !imgError) {
return (
<img
className={cn(avatarClassName, className)}
style={style}
alt={name}
src={avatar}
onError={handleError}
/>
)
}
return (
<div
className={cn(avatarClassName, className)}
style={style}
>
<div
className={cn(textClassName, 'text-center text-white scale-[0.4]')}
style={style}
>
{name[0].toLocaleUpperCase()}
</div>
</div>
)
}
export default Avatar

View File

@@ -0,0 +1,30 @@
import { memo } from 'react'
import cn from '@/utils/classnames'
type BadgeProps = {
className?: string
text?: string
children?: React.ReactNode
uppercase?: boolean
}
const Badge = ({
className,
text,
children,
uppercase = true,
}: BadgeProps) => {
return (
<div
className={cn(
'inline-flex items-center px-[5px] h-5 rounded-[5px] border border-divider-deep leading-3 text-text-tertiary',
uppercase ? 'system-2xs-medium-uppercase' : 'system-xs-medium',
className,
)}
>
{children || text}
</div>
)
}
export default memo(Badge)

View File

@@ -0,0 +1,146 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { varHighlightHTML } from '../../app/configuration/base/var-highlight'
import Toast from '../toast'
import classNames from '@/utils/classnames'
import { checkKeys } from '@/utils/var'
// regex to match the {{}} and replace it with a span
const regex = /\{\{([^}]+)\}\}/g
export const getInputKeys = (value: string) => {
const keys = value.match(regex)?.map((item) => {
return item.replace('{{', '').replace('}}', '')
}) || []
const keyObj: Record<string, boolean> = {}
// remove duplicate keys
const res: string[] = []
keys.forEach((key) => {
if (keyObj[key])
return
keyObj[key] = true
res.push(key)
})
return res
}
export type IBlockInputProps = {
value: string
className?: string // wrapper class
highLightClassName?: string // class for the highlighted text default is text-blue-500
readonly?: boolean
onConfirm?: (value: string, keys: string[]) => void
}
const BlockInput: FC<IBlockInputProps> = ({
value = '',
className,
readonly = false,
onConfirm,
}) => {
const { t } = useTranslation()
// current is used to store the current value of the contentEditable element
const [currentValue, setCurrentValue] = useState<string>(value)
useEffect(() => {
setCurrentValue(value)
}, [value])
const contentEditableRef = useRef<HTMLTextAreaElement>(null)
const [isEditing, setIsEditing] = useState<boolean>(false)
useEffect(() => {
if (isEditing && contentEditableRef.current) {
// TODO: Focus at the click position
if (currentValue)
contentEditableRef.current.setSelectionRange(currentValue.length, currentValue.length)
contentEditableRef.current.focus()
}
}, [isEditing])
const style = classNames({
'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': true,
'block-input--editing': isEditing,
})
const coloredContent = (currentValue || '')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(regex, varHighlightHTML({ name: '$1' })) // `<span class="${highLightClassName}">{{$1}}</span>`
.replace(/\n/g, '<br />')
// Not use useCallback. That will cause out callback get old data.
const handleSubmit = (value: string) => {
if (onConfirm) {
const keys = getInputKeys(value)
const { isValid, errorKey, errorMessageKey } = checkKeys(keys)
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }),
})
return
}
onConfirm(value, keys)
}
}
const onValueChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setCurrentValue(value)
handleSubmit(value)
}, [])
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
const TextAreaContentView = () => {
return <div
className={classNames(style, className)}
dangerouslySetInnerHTML={{ __html: coloredContent }}
suppressContentEditableWarning={true}
/>
}
const placeholder = ''
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
const textAreaContent = (
<div className={classNames(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', ' overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
{isEditing
? <div className='h-full px-4 py-2'>
<textarea
ref={contentEditableRef}
className={classNames(editAreaClassName, 'block w-full h-full resize-none')}
placeholder={placeholder}
onChange={onValueChange}
value={currentValue}
onBlur={() => {
blur()
setIsEditing(false)
// click confirm also make blur. Then outer value is change. So below code has problem.
// setTimeout(() => {
// handleCancel()
// }, 1000)
}}
/>
</div>
: <TextAreaContentView />}
</div>)
return (
<div className={classNames('block-input w-full overflow-y-auto bg-white border-none rounded-xl')}>
{textAreaContent}
{/* footer */}
{!readonly && (
<div className='pl-4 pb-2 flex'>
<div className="h-[18px] leading-[18px] px-1 rounded-md bg-gray-100 text-xs text-gray-500">{currentValue?.length}</div>
</div>
)}
</div>
)
}
export default React.memo(BlockInput)

View File

@@ -0,0 +1,22 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { RiAddLine } from '@remixicon/react'
import cn from '@/utils/classnames'
type Props = {
className?: string
onClick: () => void
}
const AddButton: FC<Props> = ({
className,
onClick,
}) => {
return (
<div className={cn(className, 'p-1 rounded-md cursor-pointer hover:bg-state-base-hover select-none')} onClick={onClick}>
<RiAddLine className='w-4 h-4 text-text-tertiary' />
</div>
)
}
export default React.memo(AddButton)

View File

@@ -0,0 +1,186 @@
@tailwind components;
@layer components {
.btn {
@apply inline-flex justify-center items-center cursor-pointer whitespace-nowrap;
}
.btn-disabled {
@apply cursor-not-allowed;
}
.btn-small {
@apply px-2 h-6 rounded-md text-xs font-medium;
}
.btn-medium {
@apply px-3.5 h-8 rounded-lg text-[13px] leading-4 font-medium;
}
.btn-large {
@apply px-4 h-9 rounded-[10px] text-sm font-semibold;
}
.btn-primary {
@apply
shadow
bg-components-button-primary-bg
border-components-button-primary-border
hover:bg-components-button-primary-bg-hover
hover:border-components-button-primary-border-hover
text-components-button-primary-text;
}
.btn-primary.btn-destructive {
@apply
bg-components-button-destructive-primary-bg
border-components-button-destructive-primary-border
hover:bg-components-button-destructive-primary-bg-hover
hover:border-components-button-destructive-primary-border-hover
text-components-button-destructive-primary-text;
}
.btn-primary.btn-disabled {
@apply
shadow-none
bg-components-button-primary-bg-disabled
border-components-button-primary-border-disabled
text-components-button-primary-text-disabled;
}
.btn-primary.btn-destructive.btn-disabled {
@apply
shadow-none
bg-components-button-destructive-primary-bg-disabled
border-components-button-destructive-primary-border-disabled
text-components-button-destructive-primary-text-disabled;
}
.btn-secondary {
@apply
border-[0.5px]
shadow-xs
bg-components-button-secondary-bg
border-components-button-secondary-border
hover:bg-components-button-secondary-bg-hover
hover:border-components-button-secondary-border-hover
text-components-button-secondary-text;
}
.btn-secondary.btn-disabled {
@apply
bg-components-button-secondary-bg-disabled
border-components-button-secondary-border-disabled
text-components-button-secondary-text-disabled;
}
.btn-secondary.btn-destructive {
@apply
bg-components-button-destructive-secondary-bg
border-components-button-destructive-secondary-border
hover:bg-components-button-destructive-secondary-bg-hover
hover:border-components-button-destructive-secondary-border-hover
text-components-button-destructive-secondary-text;
}
.btn-secondary.btn-destructive.btn-disabled {
@apply
bg-components-button-destructive-secondary-bg-disabled
border-components-button-destructive-secondary-border-disabled
text-components-button-destructive-secondary-text-disabled;
}
.btn-secondary-accent {
@apply
border-[0.5px]
shadow-xs
bg-components-button-secondary-bg
border-components-button-secondary-border
hover:bg-components-button-secondary-bg-hover
hover:border-components-button-secondary-border-hover
text-components-button-secondary-accent-text;
}
.btn-secondary-accent.btn-disabled {
@apply
bg-components-button-secondary-bg-disabled
border-components-button-secondary-border-disabled
text-components-button-secondary-accent-text-disabled;
}
.btn-warning {
@apply
bg-components-button-destructive-primary-bg
border-components-button-destructive-primary-border
hover:bg-components-button-destructive-primary-bg-hover
hover:border-components-button-destructive-primary-border-hover
text-components-button-destructive-primary-text;
}
.btn-warning.btn-disabled {
@apply
bg-components-button-destructive-primary-bg-disabled
border-components-button-destructive-primary-border-disabled
text-components-button-destructive-primary-text-disabled;
}
.btn-tertiary {
@apply
bg-components-button-tertiary-bg
hover:bg-components-button-tertiary-bg-hover
text-components-button-tertiary-text;
}
.btn-tertiary.btn-disabled {
@apply
bg-components-button-tertiary-bg-disabled
text-components-button-tertiary-text-disabled;
}
.btn-tertiary.btn-destructive {
@apply
bg-components-button-destructive-tertiary-bg
hover:bg-components-button-destructive-tertiary-bg-hover
text-components-button-destructive-tertiary-text;
}
.btn-tertiary.btn-destructive.btn-disabled {
@apply
bg-components-button-destructive-tertiary-bg-disabled
text-components-button-destructive-tertiary-text-disabled;
}
.btn-ghost {
@apply
hover:bg-components-button-ghost-bg-hover
text-components-button-ghost-text;
}
.btn-ghost.btn-disabled {
@apply
text-components-button-ghost-text-disabled;
}
.btn-ghost.btn-destructive {
@apply
hover:bg-components-button-destructive-ghost-bg-hover
text-components-button-destructive-ghost-text;
}
.btn-ghost.btn-destructive.btn-disabled {
@apply
text-components-button-destructive-ghost-text-disabled;
}
.btn-ghost-accent {
@apply
hover:bg-state-accent-hover
text-components-button-secondary-accent-text;
}
.btn-ghost-accent.btn-disabled {
@apply
text-components-button-secondary-accent-text-disabled;
}
}

View File

@@ -0,0 +1,49 @@
import React from 'react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import Button from './index'
afterEach(cleanup)
// https://testing-library.com/docs/queries/about
describe('Button text', () => {
test('Button text should be same as children', async () => {
const { getByRole, container } = render(<Button>Click me</Button>)
expect(getByRole('button').textContent).toBe('Click me')
expect(container.querySelector('button')?.textContent).toBe('Click me')
})
test('Loading button text should include same as children', async () => {
const { getByRole } = render(<Button loading>Click me</Button>)
expect(getByRole('button').textContent?.includes('Loading')).toBe(true)
})
})
describe('Button style', () => {
test('Button should have default variant', async () => {
const { getByRole } = render(<Button>Click me</Button>)
expect(getByRole('button').className).toContain('btn-secondary')
})
test('Button should have primary variant', async () => {
const { getByRole } = render(<Button variant='primary'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-primary')
})
test('Button should have warning variant', async () => {
const { getByRole } = render(<Button variant='warning'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-warning')
})
test('Button disabled should have disabled variant', async () => {
const { getByRole } = render(<Button disabled>Click me</Button>)
expect(getByRole('button').className).toContain('btn-disabled')
})
})
describe('Button events', () => {
test('onClick should been call after clicked', async () => {
const onClick = jest.fn()
const { getByRole } = render(<Button onClick={onClick}>Click me</Button>)
fireEvent.click(getByRole('button'))
expect(onClick).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from '@storybook/react'
import { fn } from '@storybook/test'
import { RocketLaunchIcon } from '@heroicons/react/20/solid'
import { Button } from '.'
const meta = {
title: 'Base/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
loading: { control: 'boolean' },
variant: {
control: 'select',
options: ['primary', 'warning', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary'],
},
},
args: {
variant: 'ghost',
onClick: fn(),
children: 'adsf',
},
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
variant: 'primary',
loading: false,
children: 'Primary Button',
},
}
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Secondary Button',
},
}
export const SecondaryAccent: Story = {
args: {
variant: 'secondary-accent',
children: 'Secondary Accent Button',
},
}
export const Ghost: Story = {
args: {
variant: 'ghost',
children: 'Ghost Button',
},
}
export const GhostAccent: Story = {
args: {
variant: 'ghost-accent',
children: 'Ghost Accent Button',
},
}
export const Tertiary: Story = {
args: {
variant: 'tertiary',
children: 'Tertiary Button',
},
}
export const Warning: Story = {
args: {
variant: 'warning',
children: 'Warning Button',
},
}
export const Disabled: Story = {
args: {
variant: 'primary',
disabled: true,
children: 'Disabled Button',
},
}
export const Loading: Story = {
args: {
variant: 'primary',
loading: true,
children: 'Loading Button',
},
}
export const WithIcon: Story = {
args: {
variant: 'primary',
children: (
<>
<RocketLaunchIcon className="h-4 w-4 mr-1.5 stroke-[1.8px]" />
Launch
</>
),
},
}

View File

@@ -0,0 +1,61 @@
import type { CSSProperties } from 'react'
import React from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import Spinner from '../spinner'
import classNames from '@/utils/classnames'
const buttonVariants = cva(
'btn disabled:btn-disabled',
{
variants: {
variant: {
'primary': 'btn-primary',
'warning': 'btn-warning',
'secondary': 'btn-secondary',
'secondary-accent': 'btn-secondary-accent',
'ghost': 'btn-ghost',
'ghost-accent': 'btn-ghost-accent',
'tertiary': 'btn-tertiary',
},
size: {
small: 'btn-small',
medium: 'btn-medium',
large: 'btn-large',
},
},
defaultVariants: {
variant: 'secondary',
size: 'medium',
},
},
)
export type ButtonProps = {
destructive?: boolean
loading?: boolean
styleCss?: CSSProperties
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, destructive, loading, styleCss, children, ...props }, ref) => {
return (
<button
type='button'
className={classNames(
buttonVariants({ variant, size, className }),
destructive && 'btn-destructive',
)}
ref={ref}
style={styleCss}
{...props}
>
{children}
{loading && <Spinner loading={loading} className='!text-white !h-3 !w-3 !border-2 !ml-1' />}
</button>
)
},
)
Button.displayName = 'Button'
export default Button
export { Button, buttonVariants }

View File

@@ -0,0 +1,42 @@
[
{
"id": "question-1",
"isAnswer": false,
"parentMessageId": null
},
{
"id": "1",
"isAnswer": true,
"parentMessageId": "question-1"
},
{
"id": "question-2",
"isAnswer": false,
"parentMessageId": "1"
},
{
"id": "2",
"isAnswer": true,
"parentMessageId": "question-2"
},
{
"id": "question-3",
"isAnswer": false,
"parentMessageId": "2"
},
{
"id": "3",
"isAnswer": true,
"parentMessageId": "question-3"
},
{
"id": "question-4",
"isAnswer": false,
"parentMessageId": "1"
},
{
"id": "4",
"isAnswer": true,
"parentMessageId": "question-4"
}
]

View File

@@ -0,0 +1,42 @@
[
{
"id": "question-1",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "1",
"isAnswer": true,
"parentMessageId": "question-1"
},
{
"id": "question-2",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "2",
"isAnswer": true,
"parentMessageId": "question-2"
},
{
"id": "question-3",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "3",
"isAnswer": true,
"parentMessageId": "question-3"
},
{
"id": "question-4",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "4",
"isAnswer": true,
"parentMessageId": "question-4"
}
]

View File

@@ -0,0 +1,42 @@
[
{
"id": "question-1",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "1",
"isAnswer": true,
"parentMessageId": "question-1"
},
{
"id": "question-2",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "2",
"isAnswer": true,
"parentMessageId": "question-2"
},
{
"id": "question-3",
"isAnswer": false,
"parentMessageId": "2"
},
{
"id": "3",
"isAnswer": true,
"parentMessageId": "question-3"
},
{
"id": "question-4",
"isAnswer": false,
"parentMessageId": "1"
},
{
"id": "4",
"isAnswer": true,
"parentMessageId": "question-4"
}
]

View File

@@ -0,0 +1,52 @@
[
{
"id": "question-1",
"isAnswer": false,
"parentMessageId": null
},
{
"id": "1",
"isAnswer": true,
"parentMessageId": "question-1"
},
{
"id": "question-2",
"isAnswer": false,
"parentMessageId": "1"
},
{
"id": "2",
"isAnswer": true,
"parentMessageId": "question-2"
},
{
"id": "question-3",
"isAnswer": false,
"parentMessageId": "2"
},
{
"id": "3",
"isAnswer": true,
"parentMessageId": "question-3"
},
{
"id": "question-4",
"isAnswer": false,
"parentMessageId": "1"
},
{
"id": "4",
"isAnswer": true,
"parentMessageId": "question-4"
},
{
"id": "question-5",
"isAnswer": false,
"parentMessageId": null
},
{
"id": "5",
"isAnswer": true,
"parentMessageId": "question-5"
}
]

View File

@@ -0,0 +1,52 @@
[
{
"id": "question-1",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "1",
"isAnswer": true,
"parentMessageId": "question-1"
},
{
"id": "question-2",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "2",
"isAnswer": true,
"parentMessageId": "question-2"
},
{
"id": "question-3",
"isAnswer": false,
"parentMessageId": "00000000-0000-0000-0000-000000000000"
},
{
"id": "3",
"isAnswer": true,
"parentMessageId": "question-3"
},
{
"id": "question-4",
"isAnswer": false,
"parentMessageId": "1"
},
{
"id": "4",
"isAnswer": true,
"parentMessageId": "question-4"
},
{
"id": "question-5",
"isAnswer": false,
"parentMessageId": null
},
{
"id": "5",
"isAnswer": true,
"parentMessageId": "question-5"
}
]

View File

@@ -0,0 +1,122 @@
[
{
"id": "question-ebb73fe2-15de-46dd-aab5-75416d8448eb",
"content": "123",
"isAnswer": false,
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
},
{
"id": "ebb73fe2-15de-46dd-aab5-75416d8448eb",
"content": "237.",
"isAnswer": true,
"parentMessageId": "question-ebb73fe2-15de-46dd-aab5-75416d8448eb"
},
{
"id": "question-3553d508-3850-462e-8594-078539f940f9",
"content": "123",
"isAnswer": false,
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
},
{
"id": "3553d508-3850-462e-8594-078539f940f9",
"content": "My number is 256.",
"isAnswer": true,
"parentMessageId": "question-3553d508-3850-462e-8594-078539f940f9"
},
{
"id": "question-507f9df9-1f06-4a57-bb38-f00228c42c22",
"content": "123",
"isAnswer": false,
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
},
{
"id": "507f9df9-1f06-4a57-bb38-f00228c42c22",
"content": "My number is 259.",
"isAnswer": true,
"parentMessageId": "question-507f9df9-1f06-4a57-bb38-f00228c42c22"
},
{
"id": "question-9e51a13b-7780-4565-98dc-f2d8c3b1758f",
"content": "1024",
"isAnswer": false,
"parentMessageId": "507f9df9-1f06-4a57-bb38-f00228c42c22"
},
{
"id": "9e51a13b-7780-4565-98dc-f2d8c3b1758f",
"content": "My number is 2048.",
"isAnswer": true,
"parentMessageId": "question-9e51a13b-7780-4565-98dc-f2d8c3b1758f"
},
{
"id": "question-93bac05d-1470-4ac9-b090-fe21cd7c3d55",
"content": "3306",
"isAnswer": false,
"parentMessageId": "9e51a13b-7780-4565-98dc-f2d8c3b1758f"
},
{
"id": "93bac05d-1470-4ac9-b090-fe21cd7c3d55",
"content": "My number is 4782.",
"isAnswer": true,
"parentMessageId": "question-93bac05d-1470-4ac9-b090-fe21cd7c3d55"
},
{
"id": "question-a956de3d-ef95-4d90-84fe-f7a26ef28cd7",
"content": "-5",
"isAnswer": false,
"parentMessageId": "93bac05d-1470-4ac9-b090-fe21cd7c3d55"
},
{
"id": "a956de3d-ef95-4d90-84fe-f7a26ef28cd7",
"content": "My number is 22.",
"isAnswer": true,
"parentMessageId": "question-a956de3d-ef95-4d90-84fe-f7a26ef28cd7"
},
{
"id": "question-3cded945-855a-4a24-aab7-43c7dd54664c",
"content": "3.11",
"isAnswer": false,
"parentMessageId": "a956de3d-ef95-4d90-84fe-f7a26ef28cd7"
},
{
"id": "3cded945-855a-4a24-aab7-43c7dd54664c",
"content": "My number is 7.89.",
"isAnswer": true,
"parentMessageId": "question-3cded945-855a-4a24-aab7-43c7dd54664c"
},
{
"id": "question-46a49bb9-0881-459e-8c6a-24d20ae48d2f",
"content": "78",
"isAnswer": false,
"parentMessageId": "3cded945-855a-4a24-aab7-43c7dd54664c"
},
{
"id": "46a49bb9-0881-459e-8c6a-24d20ae48d2f",
"content": "My number is 145.",
"isAnswer": true,
"parentMessageId": "question-46a49bb9-0881-459e-8c6a-24d20ae48d2f"
},
{
"id": "question-5c56a2b3-f057-42a0-9b2c-52a35713cd8c",
"content": "π",
"isAnswer": false,
"parentMessageId": "46a49bb9-0881-459e-8c6a-24d20ae48d2f"
},
{
"id": "5c56a2b3-f057-42a0-9b2c-52a35713cd8c",
"content": "My number is 2π (approximately 6.28).",
"isAnswer": true,
"parentMessageId": "question-5c56a2b3-f057-42a0-9b2c-52a35713cd8c"
},
{
"id": "question-9eac3bcc-8d3b-4e56-a12b-44c34cebc719",
"content": "e",
"isAnswer": false,
"parentMessageId": "5c56a2b3-f057-42a0-9b2c-52a35713cd8c"
},
{
"id": "9eac3bcc-8d3b-4e56-a12b-44c34cebc719",
"content": "My number is 3e (approximately 8.15).",
"isAnswer": true,
"parentMessageId": "question-9eac3bcc-8d3b-4e56-a12b-44c34cebc719"
}
]

View File

@@ -0,0 +1,441 @@
[
{
"id": "question-ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
"content": "Let's play a game, I say a number , and you response me with another bigger, yet random-looking number. I'll start first, 38",
"isAnswer": false,
"message_files": []
},
{
"id": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
"content": "Sure, I'll play! My number is 57. Your turn!",
"agent_thoughts": [
{
"id": "f9d7ff7c-3a3b-4d9a-a289-657817f4caff",
"chain_id": null,
"message_id": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
"position": 1,
"thought": "Sure, I'll play! My number is 57. Your turn!",
"tool": "",
"tool_labels": {},
"tool_input": "",
"created_at": 1726105791,
"observation": "",
"files": []
}
],
"feedbackDisabled": false,
"isAnswer": true,
"message_files": [],
"log": [
{
"role": "user",
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
"files": []
},
{
"role": "assistant",
"text": "Sure, I'll play! My number is 57. Your turn!",
"files": []
}
],
"workflow_run_id": null,
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
"input": {
"inputs": {},
"query": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38"
},
"more": {
"time": "09/11/2024 09:49 PM",
"tokens": 49,
"latency": "1.56"
},
"parentMessageId": "question-ff4c2b43-48a5-47ad-9dc5-08b34ddba61b"
},
{
"id": "question-73bbad14-d915-499d-87bf-0df14d40779d",
"content": "58",
"isAnswer": false,
"message_files": [],
"parentMessageId": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b"
},
{
"id": "73bbad14-d915-499d-87bf-0df14d40779d",
"content": "I choose 83. What's your next number?",
"agent_thoughts": [
{
"id": "f61a3fce-37ac-4f9d-9935-95f97e598dfe",
"chain_id": null,
"message_id": "73bbad14-d915-499d-87bf-0df14d40779d",
"position": 1,
"thought": "I choose 83. What's your next number?",
"tool": "",
"tool_labels": {},
"tool_input": "",
"created_at": 1726105795,
"observation": "",
"files": []
}
],
"feedbackDisabled": false,
"isAnswer": true,
"message_files": [],
"log": [
{
"role": "user",
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
"files": []
},
{
"role": "assistant",
"text": "Sure, I'll play! My number is 57. Your turn!",
"files": []
},
{
"role": "user",
"text": "58",
"files": []
},
{
"role": "assistant",
"text": "I choose 83. What's your next number?",
"files": []
}
],
"workflow_run_id": null,
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
"input": {
"inputs": {},
"query": "58"
},
"more": {
"time": "09/11/2024 09:49 PM",
"tokens": 68,
"latency": "1.33"
},
"parentMessageId": "question-73bbad14-d915-499d-87bf-0df14d40779d"
},
{
"id": "question-4c5d0841-1206-463e-95d8-71f812877658",
"content": "99",
"isAnswer": false,
"message_files": [],
"parentMessageId": "73bbad14-d915-499d-87bf-0df14d40779d"
},
{
"id": "4c5d0841-1206-463e-95d8-71f812877658",
"content": "I'll go with 112. Your turn!",
"agent_thoughts": [
{
"id": "9730d587-9268-4683-9dd9-91a1cab9510b",
"chain_id": null,
"message_id": "4c5d0841-1206-463e-95d8-71f812877658",
"position": 1,
"thought": "I'll go with 112. Your turn!",
"tool": "",
"tool_labels": {},
"tool_input": "",
"created_at": 1726105799,
"observation": "",
"files": []
}
],
"feedbackDisabled": false,
"isAnswer": true,
"message_files": [],
"log": [
{
"role": "user",
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
"files": []
},
{
"role": "assistant",
"text": "Sure, I'll play! My number is 57. Your turn!",
"files": []
},
{
"role": "user",
"text": "58",
"files": []
},
{
"role": "assistant",
"text": "I choose 83. What's your next number?",
"files": []
},
{
"role": "user",
"text": "99",
"files": []
},
{
"role": "assistant",
"text": "I'll go with 112. Your turn!",
"files": []
}
],
"workflow_run_id": null,
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
"input": {
"inputs": {},
"query": "99"
},
"more": {
"time": "09/11/2024 09:50 PM",
"tokens": 86,
"latency": "1.49"
},
"parentMessageId": "question-4c5d0841-1206-463e-95d8-71f812877658"
},
{
"id": "question-cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
"content": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
"isAnswer": false,
"message_files": []
},
{
"id": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
"content": "Sure! My number is 54. Your turn!",
"agent_thoughts": [
{
"id": "1019cd79-d141-4f9f-880a-fc1441cfd802",
"chain_id": null,
"message_id": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
"position": 1,
"thought": "Sure! My number is 54. Your turn!",
"tool": "",
"tool_labels": {},
"tool_input": "",
"created_at": 1726105809,
"observation": "",
"files": []
}
],
"feedbackDisabled": false,
"isAnswer": true,
"message_files": [],
"log": [
{
"role": "user",
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
"files": []
},
{
"role": "assistant",
"text": "Sure! My number is 54. Your turn!",
"files": []
}
],
"workflow_run_id": null,
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
"input": {
"inputs": {},
"query": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38"
},
"more": {
"time": "09/11/2024 09:50 PM",
"tokens": 46,
"latency": "1.52"
},
"parentMessageId": "question-cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
},
{
"id": "question-324bce32-c98c-435d-a66b-bac974ebb5ed",
"content": "3306",
"isAnswer": false,
"message_files": [],
"parentMessageId": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
},
{
"id": "324bce32-c98c-435d-a66b-bac974ebb5ed",
"content": "My number is 4729. Your turn!",
"agent_thoughts": [
{
"id": "0773bec7-b992-4a53-92b2-20ebaeae8798",
"chain_id": null,
"message_id": "324bce32-c98c-435d-a66b-bac974ebb5ed",
"position": 1,
"thought": "My number is 4729. Your turn!",
"tool": "",
"tool_labels": {},
"tool_input": "",
"created_at": 1726105822,
"observation": "",
"files": []
}
],
"feedbackDisabled": false,
"isAnswer": true,
"message_files": [],
"log": [
{
"role": "user",
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
"files": []
},
{
"role": "assistant",
"text": "Sure! My number is 54. Your turn!",
"files": []
},
{
"role": "user",
"text": "3306",
"files": []
},
{
"role": "assistant",
"text": "My number is 4729. Your turn!",
"files": []
}
],
"workflow_run_id": null,
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
"input": {
"inputs": {},
"query": "3306"
},
"more": {
"time": "09/11/2024 09:50 PM",
"tokens": 66,
"latency": "1.30"
},
"parentMessageId": "question-324bce32-c98c-435d-a66b-bac974ebb5ed"
},
{
"id": "question-684b5396-4e91-4043-88e9-aabe48b21acc",
"content": "3306",
"isAnswer": false,
"message_files": [],
"parentMessageId": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
},
{
"id": "684b5396-4e91-4043-88e9-aabe48b21acc",
"content": "My number is 4821. Your turn!",
"agent_thoughts": [
{
"id": "5ca650f3-982c-4399-8b95-9ea241c76707",
"chain_id": null,
"message_id": "684b5396-4e91-4043-88e9-aabe48b21acc",
"position": 1,
"thought": "My number is 4821. Your turn!",
"tool": "",
"tool_labels": {},
"tool_input": "",
"created_at": 1726107812,
"observation": "",
"files": []
}
],
"feedbackDisabled": false,
"isAnswer": true,
"message_files": [],
"log": [
{
"role": "user",
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
"files": []
},
{
"role": "assistant",
"text": "Sure! My number is 54. Your turn!",
"files": []
},
{
"role": "user",
"text": "3306",
"files": []
},
{
"role": "assistant",
"text": "My number is 4821. Your turn!",
"files": []
}
],
"workflow_run_id": null,
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
"input": {
"inputs": {},
"query": "3306"
},
"more": {
"time": "09/11/2024 10:23 PM",
"tokens": 66,
"latency": "1.48"
},
"parentMessageId": "question-684b5396-4e91-4043-88e9-aabe48b21acc"
},
{
"id": "question-19904a7b-7494-4ed8-b72c-1d18668cea8c",
"content": "1003",
"isAnswer": false,
"message_files": [],
"parentMessageId": "684b5396-4e91-4043-88e9-aabe48b21acc"
},
{
"id": "19904a7b-7494-4ed8-b72c-1d18668cea8c",
"content": "My number is 1456. Your turn!",
"agent_thoughts": [
{
"id": "095cacab-afad-4387-a41d-1662578b8b13",
"chain_id": null,
"message_id": "19904a7b-7494-4ed8-b72c-1d18668cea8c",
"position": 1,
"thought": "My number is 1456. Your turn!",
"tool": "",
"tool_labels": {},
"tool_input": "",
"created_at": 1726111024,
"observation": "",
"files": []
}
],
"feedbackDisabled": false,
"isAnswer": true,
"message_files": [],
"log": [
{
"role": "user",
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
"files": []
},
{
"role": "assistant",
"text": "Sure! My number is 54. Your turn!",
"files": []
},
{
"role": "user",
"text": "3306",
"files": []
},
{
"role": "assistant",
"text": "My number is 4821. Your turn!",
"files": []
},
{
"role": "user",
"text": "1003",
"files": []
},
{
"role": "assistant",
"text": "My number is 1456. Your turn!",
"files": []
}
],
"workflow_run_id": null,
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
"input": {
"inputs": {},
"query": "1003"
},
"more": {
"time": "09/11/2024 11:17 PM",
"tokens": 86,
"latency": "1.38"
},
"parentMessageId": "question-19904a7b-7494-4ed8-b72c-1d18668cea8c"
}
]

View File

@@ -0,0 +1,271 @@
import { get } from 'lodash'
import { buildChatItemTree, getThreadMessages } from '../utils'
import type { ChatItemInTree } from '../types'
import branchedTestMessages from './branchedTestMessages.json'
import legacyTestMessages from './legacyTestMessages.json'
import mixedTestMessages from './mixedTestMessages.json'
import multiRootNodesMessages from './multiRootNodesMessages.json'
import multiRootNodesWithLegacyTestMessages from './multiRootNodesWithLegacyTestMessages.json'
import realWorldMessages from './realWorldMessages.json'
import partialMessages from './partialMessages.json'
function visitNode(tree: ChatItemInTree | ChatItemInTree[], path: string): ChatItemInTree {
return get(tree, path)
}
describe('build chat item tree and get thread messages', () => {
const tree1 = buildChatItemTree(branchedTestMessages as ChatItemInTree[])
it('should build chat item tree1', () => {
const a1 = visitNode(tree1, '0.children.0')
expect(a1.id).toBe('1')
expect(a1.children).toHaveLength(2)
const a2 = visitNode(a1, 'children.0.children.0')
expect(a2.id).toBe('2')
expect(a2.siblingIndex).toBe(0)
const a3 = visitNode(a2, 'children.0.children.0')
expect(a3.id).toBe('3')
const a4 = visitNode(a1, 'children.1.children.0')
expect(a4.id).toBe('4')
expect(a4.siblingIndex).toBe(1)
})
it('should get thread messages from tree1, using the last message as the target', () => {
const threadChatItems1_1 = getThreadMessages(tree1)
expect(threadChatItems1_1).toHaveLength(4)
const q1 = visitNode(threadChatItems1_1, '0')
const a1 = visitNode(threadChatItems1_1, '1')
const q4 = visitNode(threadChatItems1_1, '2')
const a4 = visitNode(threadChatItems1_1, '3')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q4.id).toBe('question-4')
expect(a4.id).toBe('4')
expect(a4.siblingCount).toBe(2)
expect(a4.siblingIndex).toBe(1)
})
it('should get thread messages from tree1, using the message with id 3 as the target', () => {
const threadChatItems1_2 = getThreadMessages(tree1, '3')
expect(threadChatItems1_2).toHaveLength(6)
const q1 = visitNode(threadChatItems1_2, '0')
const a1 = visitNode(threadChatItems1_2, '1')
const q2 = visitNode(threadChatItems1_2, '2')
const a2 = visitNode(threadChatItems1_2, '3')
const q3 = visitNode(threadChatItems1_2, '4')
const a3 = visitNode(threadChatItems1_2, '5')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q2.id).toBe('question-2')
expect(a2.id).toBe('2')
expect(q3.id).toBe('question-3')
expect(a3.id).toBe('3')
expect(a2.siblingCount).toBe(2)
expect(a2.siblingIndex).toBe(0)
})
const tree2 = buildChatItemTree(legacyTestMessages as ChatItemInTree[])
it('should work with legacy chat items', () => {
expect(tree2).toHaveLength(1)
const q1 = visitNode(tree2, '0')
const a1 = visitNode(q1, 'children.0')
const q2 = visitNode(a1, 'children.0')
const a2 = visitNode(q2, 'children.0')
const q3 = visitNode(a2, 'children.0')
const a3 = visitNode(q3, 'children.0')
const q4 = visitNode(a3, 'children.0')
const a4 = visitNode(q4, 'children.0')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q2.id).toBe('question-2')
expect(a2.id).toBe('2')
expect(q3.id).toBe('question-3')
expect(a3.id).toBe('3')
expect(q4.id).toBe('question-4')
expect(a4.id).toBe('4')
})
it('should get thread messages from tree2, using the last message as the target', () => {
const threadMessages2 = getThreadMessages(tree2)
expect(threadMessages2).toHaveLength(8)
const q1 = visitNode(threadMessages2, '0')
const a1 = visitNode(threadMessages2, '1')
const q2 = visitNode(threadMessages2, '2')
const a2 = visitNode(threadMessages2, '3')
const q3 = visitNode(threadMessages2, '4')
const a3 = visitNode(threadMessages2, '5')
const q4 = visitNode(threadMessages2, '6')
const a4 = visitNode(threadMessages2, '7')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q2.id).toBe('question-2')
expect(a2.id).toBe('2')
expect(q3.id).toBe('question-3')
expect(a3.id).toBe('3')
expect(q4.id).toBe('question-4')
expect(a4.id).toBe('4')
expect(a1.siblingCount).toBe(1)
expect(a1.siblingIndex).toBe(0)
expect(a2.siblingCount).toBe(1)
expect(a2.siblingIndex).toBe(0)
expect(a3.siblingCount).toBe(1)
expect(a3.siblingIndex).toBe(0)
expect(a4.siblingCount).toBe(1)
expect(a4.siblingIndex).toBe(0)
})
const tree3 = buildChatItemTree(mixedTestMessages as ChatItemInTree[])
it('should build mixed chat items tree', () => {
expect(tree3).toHaveLength(1)
const a1 = visitNode(tree3, '0.children.0')
expect(a1.id).toBe('1')
expect(a1.children).toHaveLength(2)
const a2 = visitNode(a1, 'children.0.children.0')
expect(a2.id).toBe('2')
expect(a2.siblingIndex).toBe(0)
const a3 = visitNode(a2, 'children.0.children.0')
expect(a3.id).toBe('3')
const a4 = visitNode(a1, 'children.1.children.0')
expect(a4.id).toBe('4')
expect(a4.siblingIndex).toBe(1)
})
it('should get thread messages from tree3, using the last message as the target', () => {
const threadMessages3_1 = getThreadMessages(tree3)
expect(threadMessages3_1).toHaveLength(4)
const q1 = visitNode(threadMessages3_1, '0')
const a1 = visitNode(threadMessages3_1, '1')
const q4 = visitNode(threadMessages3_1, '2')
const a4 = visitNode(threadMessages3_1, '3')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q4.id).toBe('question-4')
expect(a4.id).toBe('4')
expect(a4.siblingCount).toBe(2)
expect(a4.siblingIndex).toBe(1)
})
it('should get thread messages from tree3, using the message with id 3 as the target', () => {
const threadMessages3_2 = getThreadMessages(tree3, '3')
expect(threadMessages3_2).toHaveLength(6)
const q1 = visitNode(threadMessages3_2, '0')
const a1 = visitNode(threadMessages3_2, '1')
const q2 = visitNode(threadMessages3_2, '2')
const a2 = visitNode(threadMessages3_2, '3')
const q3 = visitNode(threadMessages3_2, '4')
const a3 = visitNode(threadMessages3_2, '5')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q2.id).toBe('question-2')
expect(a2.id).toBe('2')
expect(q3.id).toBe('question-3')
expect(a3.id).toBe('3')
expect(a2.siblingCount).toBe(2)
expect(a2.siblingIndex).toBe(0)
})
const tree4 = buildChatItemTree(multiRootNodesMessages as ChatItemInTree[])
it('should build multi root nodes chat items tree', () => {
expect(tree4).toHaveLength(2)
const a5 = visitNode(tree4, '1.children.0')
expect(a5.id).toBe('5')
expect(a5.siblingIndex).toBe(1)
})
it('should get thread messages from tree4, using the last message as the target', () => {
const threadMessages4 = getThreadMessages(tree4)
expect(threadMessages4).toHaveLength(2)
const a1 = visitNode(threadMessages4, '0.children.0')
expect(a1.id).toBe('5')
})
it('should get thread messages from tree4, using the message with id 2 as the target', () => {
const threadMessages4_1 = getThreadMessages(tree4, '2')
expect(threadMessages4_1).toHaveLength(6)
const a1 = visitNode(threadMessages4_1, '1')
expect(a1.id).toBe('1')
const a2 = visitNode(threadMessages4_1, '3')
expect(a2.id).toBe('2')
const a3 = visitNode(threadMessages4_1, '5')
expect(a3.id).toBe('3')
})
const tree5 = buildChatItemTree(multiRootNodesWithLegacyTestMessages as ChatItemInTree[])
it('should work with multi root nodes chat items with legacy chat items', () => {
expect(tree5).toHaveLength(2)
const q5 = visitNode(tree5, '1')
expect(q5.id).toBe('question-5')
expect(q5.parentMessageId).toBe(null)
const a5 = visitNode(q5, 'children.0')
expect(a5.id).toBe('5')
expect(a5.children).toHaveLength(0)
})
it('should get thread messages from tree5, using the last message as the target', () => {
const threadMessages5 = getThreadMessages(tree5)
expect(threadMessages5).toHaveLength(2)
const q5 = visitNode(threadMessages5, '0')
const a5 = visitNode(threadMessages5, '1')
expect(q5.id).toBe('question-5')
expect(a5.id).toBe('5')
expect(a5.siblingCount).toBe(2)
expect(a5.siblingIndex).toBe(1)
})
const tree6 = buildChatItemTree(realWorldMessages as ChatItemInTree[])
it('should work with real world messages', () => {
expect(tree6).toMatchSnapshot()
})
it ('should get thread messages from tree6, using the last message as target', () => {
const threadMessages6_1 = getThreadMessages(tree6)
expect(threadMessages6_1).toMatchSnapshot()
})
it ('should get thread messages from tree6, using specified message as target', () => {
const threadMessages6_2 = getThreadMessages(tree6, 'ff4c2b43-48a5-47ad-9dc5-08b34ddba61b')
expect(threadMessages6_2).toMatchSnapshot()
})
const partialMessages1 = (realWorldMessages as ChatItemInTree[]).slice(-10)
const tree7 = buildChatItemTree(partialMessages1)
it('should work with partial messages 1', () => {
expect(tree7).toMatchSnapshot()
})
const partialMessages2 = (partialMessages as ChatItemInTree[])
const tree8 = buildChatItemTree(partialMessages2)
it('should work with partial messages 2', () => {
expect(tree8).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,186 @@
import { useCallback, useEffect, useMemo } 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 Header from './header'
import ConfigPanel from './config-panel'
import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
} from '@/service/share'
import AnswerIcon from '@/app/components/base/answer-icon'
const ChatWrapper = () => {
const {
appParams,
appPrevChatTree,
currentConversationId,
currentConversationItem,
inputsForms,
newConversationInputs,
handleNewConversationCompleted,
isMobile,
isInstalledApp,
appId,
appMeta,
handleFeedback,
currentChatInstanceRef,
appData,
themeBuilder,
} = 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: currentConversationId ? currentConversationItem?.introduction : (config as any).opening_statement,
} as ChatConfig
}, [appParams, currentConversationItem?.introduction, currentConversationId])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
isResponding,
suggestedQuestions,
} = useChat(
appConfig,
{
inputs: (currentConversationId ? currentConversationItem?.inputs : newConversationInputs) as any,
inputsForm: inputsForms,
},
appPrevChatTree,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
)
useEffect(() => {
if (currentChatInstanceRef.current)
currentChatInstanceRef.current.handleStop = handleStop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
files,
inputs: currentConversationId ? currentConversationItem?.inputs : 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,
currentConversationItem,
newConversationInputs,
isInstalledApp,
appId,
])
const doRegenerate = useCallback((chatItem: ChatItemInTree) => {
const question = chatList.find(item => item.id === chatItem.parentMessageId)!
const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
const chatNode = useMemo(() => {
if (inputsForms.length) {
return (
<>
<Header
isMobile={isMobile}
title={currentConversationItem?.name || ''}
/>
{
!currentConversationId && (
<div className={`mx-auto w-full max-w-[720px] ${isMobile && 'px-4'}`}>
<div className='mb-6' />
<ConfigPanel />
<div
className='my-6 h-[1px]'
style={{ background: 'linear-gradient(90deg, rgba(242, 244, 247, 0.00) 0%, #F2F4F7 49.17%, rgba(242, 244, 247, 0.00) 100%)' }}
/>
</div>
)
}
</>
)
}
return (
<Header
isMobile={isMobile}
title={currentConversationItem?.name || ''}
/>
)
}, [
currentConversationId,
inputsForms,
currentConversationItem,
isMobile,
])
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
? <AnswerIcon
iconType={appData.site.icon_type}
icon={appData.site.icon}
background={appData.site.icon_background}
imageUrl={appData.site.icon_url}
/>
: null
return (
<div
className='h-full bg-chatbot-bg overflow-hidden'
>
<Chat
appData={appData}
config={appConfig}
chatList={chatList}
isResponding={isResponding}
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[720px] ${isMobile && 'px-4'}`}
chatFooterClassName='pb-4'
chatFooterInnerClassName={`mx-auto w-full max-w-[720px] ${isMobile && 'px-4'}`}
onSend={doSend}
inputs={currentConversationId ? currentConversationItem?.inputs as any : newConversationInputs}
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
chatNode={chatNode}
allToolIcons={appMeta?.tool_icons || {}}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
/>
</div>
)
}
export default ChatWrapper

View File

@@ -0,0 +1,47 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import Textarea from '@/app/components/base/textarea'
type InputProps = {
form: any
value: string
onChange: (variable: string, value: string) => void
}
const FormInput: FC<InputProps> = ({
form,
value,
onChange,
}) => {
const { t } = useTranslation()
const {
type,
label,
required,
max_length,
variable,
} = form
if (type === 'paragraph') {
return (
<Textarea
value={value}
className='resize-none'
onChange={e => onChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
return (
<input
className='grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none'
value={value || ''}
maxLength={max_length}
onChange={e => onChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
export default memo(FormInput)

View File

@@ -0,0 +1,117 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import Input from './form-input'
import { PortalSelect } from '@/app/components/base/select'
import { InputVarType } from '@/app/components/workflow/types'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
const Form = () => {
const { t } = useTranslation()
const {
appParams,
inputsForms,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
isMobile,
} = useChatWithHistoryContext()
const handleFormChange = useCallback((variable: string, value: any) => {
handleNewConversationInputsChange({
...newConversationInputsRef.current,
[variable]: value,
})
}, [newConversationInputsRef, handleNewConversationInputsChange])
const renderField = (form: any) => {
const {
label,
required,
variable,
options,
} = form
if (form.type === 'text-input' || form.type === 'paragraph') {
return (
<Input
form={form}
value={newConversationInputs[variable]}
onChange={handleFormChange}
/>
)
}
if (form.type === 'number') {
return (
<input
className="grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none"
type="number"
value={newConversationInputs[variable] || ''}
onChange={e => handleFormChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
if (form.type === InputVarType.singleFile) {
return (
<FileUploaderInAttachmentWrapper
value={newConversationInputs[variable] ? [newConversationInputs[variable]] : []}
onChange={files => handleFormChange(variable, files[0])}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
allowed_file_upload_methods: form.allowed_file_upload_methods,
number_limits: 1,
fileUploadConfig: (appParams as any).system_parameters,
}}
/>
)
}
if (form.type === InputVarType.multiFiles) {
return (
<FileUploaderInAttachmentWrapper
value={newConversationInputs[variable]}
onChange={files => handleFormChange(variable, files)}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
allowed_file_upload_methods: form.allowed_file_upload_methods,
number_limits: form.max_length,
fileUploadConfig: (appParams as any).system_parameters,
}}
/>
)
}
return (
<PortalSelect
popupClassName='w-[200px]'
value={newConversationInputs[variable]}
items={options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(variable, item.value as string)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
if (!inputsForms.length)
return null
return (
<div className='mb-4 py-2'>
{
inputsForms.map(form => (
<div
key={form.variable}
className={`flex mb-3 last-of-type:mb-0 text-sm text-gray-900 ${isMobile && '!flex-wrap'}`}
>
<div className={`shrink-0 mr-2 py-2 w-[128px] ${isMobile && '!w-full'}`}>{form.label}</div>
{renderField(form)}
</div>
))
}
</div>
)
}
export default Form

View File

@@ -0,0 +1,172 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import Form from './form'
import Button from '@/app/components/base/button'
import AppIcon from '@/app/components/base/app-icon'
import { MessageDotsCircle } from '@/app/components/base/icons/src/vender/solid/communication'
import { Edit02 } from '@/app/components/base/icons/src/vender/line/general'
import { Star06 } from '@/app/components/base/icons/src/vender/solid/shapes'
import LogoSite from '@/app/components/base/logo/logo-site'
const ConfigPanel = () => {
const { t } = useTranslation()
const {
appData,
inputsForms,
handleStartChat,
showConfigPanelBeforeChat,
isMobile,
} = useChatWithHistoryContext()
const [collapsed, setCollapsed] = useState(true)
const customConfig = appData?.custom_config
const site = appData?.site
return (
<div className='flex flex-col max-h-[80%] w-full max-w-[720px]'>
<div
className={`
grow rounded-xl overflow-y-auto
${showConfigPanelBeforeChat && 'border-[0.5px] border-gray-100 shadow-lg'}
${!showConfigPanelBeforeChat && collapsed && 'border border-indigo-100'}
${!showConfigPanelBeforeChat && !collapsed && 'border-[0.5px] border-gray-100 shadow-lg'}
`}
>
<div
className={`
flex flex-wrap px-6 py-4 rounded-t-xl bg-indigo-25
${isMobile && '!px-4 !py-3'}
`}
>
{
showConfigPanelBeforeChat && (
<>
<div className='flex items-center h-8 text-2xl font-semibold text-gray-800'>
<AppIcon
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background='transparent'
imageUrl={appData?.site.icon_url}
size='small'
className="mr-2"
/>
{appData?.site.title}
</div>
{
appData?.site.description && (
<div className='mt-2 w-full text-sm text-gray-500'>
{appData?.site.description}
</div>
)
}
</>
)
}
{
!showConfigPanelBeforeChat && collapsed && (
<>
<Star06 className='mr-1 mt-1 w-4 h-4 text-indigo-600' />
<div className='grow py-[3px] text-[13px] text-indigo-600 leading-[18px] font-medium'>
{t('share.chat.configStatusDes')}
</div>
<Button
variant='secondary-accent'
size='small'
className='shrink-0'
onClick={() => setCollapsed(false)}
>
<Edit02 className='mr-1 w-3 h-3' />
{t('common.operation.edit')}
</Button>
</>
)
}
{
!showConfigPanelBeforeChat && !collapsed && (
<>
<Star06 className='mr-1 mt-1 w-4 h-4 text-indigo-600' />
<div className='grow py-[3px] text-[13px] text-indigo-600 leading-[18px] font-medium'>
{t('share.chat.privatePromptConfigTitle')}
</div>
</>
)
}
</div>
{
!collapsed && !showConfigPanelBeforeChat && (
<div className='p-6 rounded-b-xl'>
<Form />
<div className={`pl-[136px] flex items-center ${isMobile && '!pl-0'}`}>
<Button
variant='primary'
className='mr-2'
onClick={() => {
setCollapsed(true)
handleStartChat()
}}
>
{t('common.operation.save')}
</Button>
<Button
onClick={() => setCollapsed(true)}
>
{t('common.operation.cancel')}
</Button>
</div>
</div>
)
}
{
showConfigPanelBeforeChat && (
<div className='p-6 rounded-b-xl'>
<Form />
<Button
className={`${inputsForms.length && !isMobile && 'ml-[136px]'}`}
variant='primary'
size='large'
onClick={handleStartChat}
>
<MessageDotsCircle className='mr-2 w-4 h-4 text-white' />
{t('share.chat.startChat')}
</Button>
</div>
)
}
</div>
{
showConfigPanelBeforeChat && (site || customConfig) && (
<div className='mt-4 flex flex-wrap justify-between items-center py-2 text-xs text-gray-400'>
{site?.privacy_policy
? <div className={`flex items-center ${isMobile && 'w-full justify-end'}`}>{t('share.chat.privacyPolicyLeft')}
<a
className='text-gray-500 px-1'
href={site?.privacy_policy}
target='_blank' rel='noopener noreferrer'>{t('share.chat.privacyPolicyMiddle')}</a>
{t('share.chat.privacyPolicyRight')}
</div>
: <div>
</div>}
{
customConfig?.remove_webapp_brand
? null
: (
<div className={`flex items-center justify-end ${isMobile && 'w-full'}`}>
<div className='flex items-center pr-3 space-x-3'>
<span className='uppercase'>{t('share.chat.poweredBy')}</span>
{
customConfig?.replace_webapp_logo
? <img src={customConfig?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
: <LogoSite className='!h-5' />
}
</div>
</div>
)
}
</div>
)
}
</div>
)
}
export default ConfigPanel

View File

@@ -0,0 +1,79 @@
'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'
export type ChatWithHistoryContextValue = {
appInfoError?: any
appInfoLoading?: boolean
appMeta?: AppMeta
appData?: AppData
appParams?: ChatConfig
appChatListDataLoading?: boolean
currentConversationId: string
currentConversationItem?: ConversationItem
appPrevChatTree: ChatItemInTree[]
pinnedConversationList: AppConversationData['data']
conversationList: AppConversationData['data']
showConfigPanelBeforeChat: boolean
newConversationInputs: Record<string, any>
newConversationInputsRef: RefObject<Record<string, any>>
handleNewConversationInputsChange: (v: Record<string, any>) => void
inputsForms: any[]
handleNewConversation: () => void
handleStartChat: () => 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
}
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
currentConversationId: '',
appPrevChatTree: [],
pinnedConversationList: [],
conversationList: [],
showConfigPanelBeforeChat: false,
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: () => {},
inputsForms: [],
handleNewConversation: () => {},
handleStartChat: () => {},
handleChangeConversation: () => {},
handlePinConversation: () => {},
handleUnpinConversation: () => {},
handleDeleteConversation: () => {},
conversationRenaming: false,
handleRenameConversation: () => {},
handleNewConversationCompleted: () => {},
chatShouldReloadKey: '',
isMobile: false,
isInstalledApp: false,
handleFeedback: () => {},
currentChatInstanceRef: { current: { handleStop: () => {} } },
})
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)

View File

@@ -0,0 +1,62 @@
import { useState } from 'react'
import { useChatWithHistoryContext } from './context'
import Sidebar from './sidebar'
import AppIcon from '@/app/components/base/app-icon'
import {
Edit05,
Menu01,
} from '@/app/components/base/icons/src/vender/line/general'
const HeaderInMobile = () => {
const {
appData,
handleNewConversation,
} = useChatWithHistoryContext()
const [showSidebar, setShowSidebar] = useState(false)
return (
<>
<div className='shrink-0 flex items-center px-3 h-[44px] border-b-[0.5px] border-b-gray-200'>
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg'
onClick={() => setShowSidebar(true)}
>
<Menu01 className='w-4 h-4 text-gray-700' />
</div>
<div className='grow flex justify-center items-center px-3'>
<AppIcon
className='mr-2'
size='tiny'
icon={appData?.site.icon}
iconType={appData?.site.icon_type}
imageUrl={appData?.site.icon_url}
background={appData?.site.icon_background}
/>
<div className='py-1 text-base font-semibold text-gray-800 truncate'>
{appData?.site.title}
</div>
</div>
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg'
onClick={handleNewConversation}
>
<Edit05 className='w-4 h-4 text-gray-700' />
</div>
</div>
{
showSidebar && (
<div className='fixed inset-0 z-50'
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
onClick={() => setShowSidebar(false)}
>
<div className='inline-block h-full bg-white' onClick={e => e.stopPropagation()}>
<Sidebar />
</div>
</div>
)
}
</>
)
}
export default HeaderInMobile

View File

@@ -0,0 +1,25 @@
import type { FC } from 'react'
import { memo } from 'react'
type HeaderProps = {
title: string
isMobile: boolean
}
const Header: FC<HeaderProps> = ({
title,
isMobile,
}) => {
return (
<div
className={`
sticky top-0 flex items-center px-8 h-16 bg-white/80 text-base font-medium
text-gray-900 border-b-[0.5px] border-b-gray-100 backdrop-blur-md z-10
${isMobile && '!h-12'}
`}
>
{title}
</div>
)
}
export default memo(Header)

View File

@@ -0,0 +1,460 @@
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 } from '../utils'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import {
delConversation,
fetchAppInfo,
fetchAppMeta,
fetchAppParams,
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/i18next-config'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
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 }))),
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 }))),
parentMessageId: `question-${item.id}`,
})
})
return newChatList
}
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
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',
} as AppData
}
return appInfo
}, [isInstalledApp, installedAppInfo, appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
useEffect(() => {
if (appData?.site.default_language)
changeLanguage(appData.site.default_language)
}, [appData])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, string>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || ''] || '', [appId, conversationIdInfo])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
if (appId) {
setConversationIdInfo({
...conversationIdInfo,
[appId || '']: changeConversationId,
})
}
}, [appId, conversationIdInfo, setConversationIdInfo])
const [showConfigPanelBeforeChat, setShowConfigPanelBeforeChat] = useState(true)
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId))
const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId))
const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
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<Record<string, any>>({})
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
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) {
return {
...item.paragraph,
type: 'paragraph',
}
}
if (item.number) {
return {
...item.number,
type: 'number',
}
}
if (item.select) {
return {
...item.select,
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
return {
...item['text-input'],
type: 'text-input',
}
})
}, [appParams])
useEffect(() => {
const conversationInputs: Record<string, any> = {}
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<ConversationItem[]>([])
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 { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required }) => required)
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])
const handleStartChat = useCallback(() => {
if (checkInputsRequired()) {
setShowConfigPanelBeforeChat(false)
setShowNewConversationItemInList(true)
}
}, [setShowConfigPanelBeforeChat, setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: () => { } })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
handleConversationIdInfoChange(conversationId)
if (conversationId === '' && !checkInputsRequired(true))
setShowConfigPanelBeforeChat(true)
else
setShowConfigPanelBeforeChat(false)
}, [handleConversationIdInfoChange, setShowConfigPanelBeforeChat, checkInputsRequired])
const handleNewConversation = useCallback(() => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
if (showNewConversationItemInList) {
handleChangeConversation('')
}
else if (currentConversationId) {
handleConversationIdInfoChange('')
setShowConfigPanelBeforeChat(true)
setShowNewConversationItemInList(true)
handleNewConversationInputsChange({})
}
}, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
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 } }, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify])
return {
appInfoError,
appInfoLoading,
isInstalledApp,
appId,
currentConversationId,
currentConversationItem,
handleConversationIdInfoChange,
appData,
appParams: appParams || {} as ChatConfig,
appMeta,
appPinnedConversationData,
appConversationData,
appConversationDataLoading,
appChatListData,
appChatListDataLoading,
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
setShowConfigPanelBeforeChat,
setShowNewConversationItemInList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
conversationDeleting,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
newConversationId,
chatShouldReloadKey,
handleFeedback,
currentChatInstanceRef,
}
}

View File

@@ -0,0 +1,229 @@
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 HeaderInMobile from './header-in-mobile'
import ConfigPanel from './config-panel'
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'
type ChatWithHistoryProps = {
className?: string
}
const ChatWithHistory: FC<ChatWithHistoryProps> = ({
className,
}) => {
const {
appInfoError,
appData,
appInfoLoading,
appPrevChatTree,
showConfigPanelBeforeChat,
appChatListDataLoading,
chatShouldReloadKey,
isMobile,
themeBuilder,
} = useChatWithHistoryContext()
const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatTree.length)
const customConfig = appData?.custom_config
const site = appData?.site
useEffect(() => {
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
if (site) {
if (customConfig)
document.title = `${site.title}`
else
document.title = `${site.title} - Powered by Dify`
}
}, [site, customConfig, themeBuilder])
if (appInfoLoading) {
return (
<Loading type='app' />
)
}
if (appInfoError) {
return (
<AppUnavailable />
)
}
return (
<div className={`h-full flex bg-white ${className} ${isMobile && 'flex-col'}`}>
{
!isMobile && (
<Sidebar />
)
}
{
isMobile && (
<HeaderInMobile />
)
}
<div className={`grow overflow-hidden ${showConfigPanelBeforeChat && !appPrevChatTree.length && 'flex items-center justify-center'}`}>
{
showConfigPanelBeforeChat && !appChatListDataLoading && !appPrevChatTree.length && (
<div className={`flex w-full items-center justify-center h-full ${isMobile && 'px-4'}`}>
<ConfigPanel />
</div>
)
}
{
appChatListDataLoading && chatReady && (
<Loading type='app' />
)
}
{
chatReady && !appChatListDataLoading && (
<ChatWrapper key={chatShouldReloadKey} />
)
}
</div>
</div>
)
}
export type ChatWithHistoryWrapProps = {
installedAppInfo?: InstalledApp
className?: string
}
const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
installedAppInfo,
className,
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const themeBuilder = useThemeContext()
const {
appInfoError,
appInfoLoading,
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
} = useChatWithHistory(installedAppInfo)
return (
<ChatWithHistoryContext.Provider value={{
appInfoError,
appInfoLoading,
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatTree,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isMobile,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
}}>
<ChatWithHistory className={className} />
</ChatWithHistoryContext.Provider>
)
}
const ChatWithHistoryWrapWithCheckToken: FC<ChatWithHistoryWrapProps> = ({
installedAppInfo,
className,
}) => {
const [initialized, setInitialized] = useState(false)
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(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 <AppUnavailable isUnknownReason={isUnknownReason} />
return (
<ChatWithHistoryWrap
installedAppInfo={installedAppInfo}
className={className}
/>
)
}
export default ChatWithHistoryWrapWithCheckToken

View File

@@ -0,0 +1,145 @@
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import List from './list'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import { Edit05 } from '@/app/components/base/icons/src/vender/line/general'
import type { ConversationItem } from '@/models/share'
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
const Sidebar = () => {
const { t } = useTranslation()
const {
appData,
pinnedConversationList,
conversationList,
handleNewConversation,
currentConversationId,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
conversationRenaming,
handleRenameConversation,
handleDeleteConversation,
isMobile,
} = useChatWithHistoryContext()
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
const handleOperate = useCallback((type: string, item: ConversationItem) => {
if (type === 'pin')
handlePinConversation(item.id)
if (type === 'unpin')
handleUnpinConversation(item.id)
if (type === 'delete')
setShowConfirm(item)
if (type === 'rename')
setShowRename(item)
}, [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 (
<div className='shrink-0 h-full flex flex-col w-[240px] border-r border-r-gray-100'>
{
!isMobile && (
<div className='shrink-0 flex p-4'>
<AppIcon
className='mr-3'
size='small'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='py-1 text-base font-semibold text-gray-800'>
{appData?.site.title}
</div>
</div>
)
}
<div className='shrink-0 p-4'>
<Button
variant='secondary-accent'
className='justify-start w-full'
onClick={handleNewConversation}
>
<Edit05 className='mr-2 w-4 h-4' />
{t('share.chat.newChat')}
</Button>
</div>
<div className='grow px-4 py-2 overflow-y-auto'>
{
!!pinnedConversationList.length && (
<div className='mb-4'>
<List
isPin
title={t('share.chat.pinnedTitle') || ''}
list={pinnedConversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
</div>
)
}
{
!!conversationList.length && (
<List
title={(pinnedConversationList.length && t('share.chat.unpinnedTitle')) || ''}
list={conversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
)
}
</div>
{appData?.site.copyright && (
<div className='px-4 pb-4 text-xs text-gray-400'>
© {(new Date()).getFullYear()} {appData?.site.copyright}
</div>
)}
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content') || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
{showRename && (
<RenameModal
isShow
onClose={handleCancelRename}
saveLoading={conversationRenaming}
name={showRename?.name || ''}
onSave={handleRename}
/>
)}
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,58 @@
import type { FC } from 'react'
import {
memo,
useRef,
} from 'react'
import { useHover } from 'ahooks'
import type { ConversationItem } from '@/models/share'
import { MessageDotsCircle } from '@/app/components/base/icons/src/vender/solid/communication'
import ItemOperation from '@/app/components/explore/item-operation'
type ItemProps = {
isPin?: boolean
item: ConversationItem
onOperate: (type: string, item: ConversationItem) => void
onChangeConversation: (conversationId: string) => void
currentConversationId: string
}
const Item: FC<ItemProps> = ({
isPin,
item,
onOperate,
onChangeConversation,
currentConversationId,
}) => {
const ref = useRef(null)
const isHovering = useHover(ref)
return (
<div
ref={ref}
key={item.id}
className={`
flex mb-0.5 last-of-type:mb-0 py-1.5 pl-3 pr-1.5 text-sm font-medium text-gray-700
rounded-lg cursor-pointer hover:bg-gray-50 group
${currentConversationId === item.id && 'text-primary-600 bg-primary-50'}
`}
onClick={() => onChangeConversation(item.id)}
>
<MessageDotsCircle className={`shrink-0 mt-1 mr-2 w-4 h-4 text-gray-400 ${currentConversationId === item.id && 'text-primary-600'}`} />
<div className='grow py-0.5 break-all' title={item.name}>{item.name}</div>
{item.id !== '' && (
<div className='shrink-0 h-6' onClick={e => e.stopPropagation()}>
<ItemOperation
isPinned={!!isPin}
isItemHovering={isHovering}
togglePin={() => onOperate(isPin ? 'unpin' : 'pin', item)}
isShowDelete
isShowRenameConversation
onRenameConversation={() => onOperate('rename', item)}
onDelete={() => onOperate('delete', item)}
/>
</div>
)}
</div>
)
}
export default memo(Item)

View File

@@ -0,0 +1,46 @@
import type { FC } from 'react'
import Item from './item'
import type { ConversationItem } from '@/models/share'
type ListProps = {
isPin?: boolean
title?: string
list: ConversationItem[]
onOperate: (type: string, item: ConversationItem) => void
onChangeConversation: (conversationId: string) => void
currentConversationId: string
}
const List: FC<ListProps> = ({
isPin,
title,
list,
onOperate,
onChangeConversation,
currentConversationId,
}) => {
return (
<div>
{
title && (
<div className='mb-0.5 px-3 h-[26px] text-xs font-medium text-gray-500'>
{title}
</div>
)
}
{
list.map(item => (
<Item
key={item.id}
isPin={isPin}
item={item}
onOperate={onOperate}
onChangeConversation={onChangeConversation}
currentConversationId={currentConversationId}
/>
))
}
</div>
)
}
export default List

View File

@@ -0,0 +1,46 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
export type IRenameModalProps = {
isShow: boolean
saveLoading: boolean
name: string
onClose: () => void
onSave: (name: string) => void
}
const RenameModal: FC<IRenameModalProps> = ({
isShow,
saveLoading,
name,
onClose,
onSave,
}) => {
const { t } = useTranslation()
const [tempName, setTempName] = useState(name)
return (
<Modal
title={t('common.chat.renameConversation')}
isShow={isShow}
onClose={onClose}
>
<div className={'mt-6 font-medium text-sm leading-[21px] text-gray-900'}>{t('common.chat.conversationName')}</div>
<input className={'mt-2 w-full rounded-lg h-10 box-border px-3 text-sm leading-10 bg-gray-100'}
value={tempName}
onChange={e => setTempName(e.target.value)}
placeholder={t('common.chat.conversationNamePlaceholder') || ''}
/>
<div className='mt-10 flex justify-end'>
<Button className='mr-2 flex-shrink-0' onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button variant='primary' className='flex-shrink-0' onClick={() => onSave(tempName)} loading={saveLoading}>{t('common.operation.save')}</Button>
</div>
</Modal>
)
}
export default React.memo(RenameModal)

View File

@@ -0,0 +1,61 @@
export const markdownContent = `
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
# Basic markdown content.
Should support **bold**, *italic*, and ~~strikethrough~~.
Should support [links](https://www.google.com).
Should support inline \`code\` blocks.
# Number list
1. First item
2. Second item
3. Third item
# Bullet list
- First item
- Second item
- Third item
# Link
[Google](https://www.google.com)
# Image
![Alt text](https://picsum.photos/200/300)
# Table
| Column 1 | Column 2 | Column 3 |
| -------- | -------- | -------- |
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
| Cell 7 | Cell 8 | Cell 9 |
# Code
\`\`\`JavaScript
const code = "code"
\`\`\`
# Blockquote
> This is a blockquote.
# Horizontal rule
---
`

View File

@@ -0,0 +1,27 @@
export const markdownContentSVG = `
\`\`\`svg
<svg width="400" height="600" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#F0F8FF"/>
<text x="50%" y="60" font-family="楷体" font-size="32" fill="#4682B4" text-anchor="middle">创意Logo设计</text>
<line x1="50" y1="80" x2="350" y2="80" stroke="#B0C4DE" stroke-width="2"/>
<text x="50%" y="120" font-family="Arial" font-size="24" fill="#708090" text-anchor="middle">科研</text>
<text x="50%" y="150" font-family="MS Mincho" font-size="20" fill="#778899" text-anchor="middle">科学研究</text>
<text x="50%" y="200" font-family="汇文明朝体" font-size="18" fill="#696969" text-anchor="middle">
<tspan x="50%" dy="25">探索未知的灯塔,</tspan>
<tspan x="50%" dy="25">照亮人类前进的道路。</tspan>
<tspan x="50%" dy="25">科研,是永不熄灭的好奇心,</tspan>
<tspan x="50%" dy="25">也是推动世界进步的引擎。</tspan>
</text>
<circle cx="200" cy="400" r="80" fill="none" stroke="#4169E1" stroke-width="3"/>
<line x1="200" y1="320" x2="200" y2="480" stroke="#4169E1" stroke-width="3"/>
<line x1="120" y1="400" x2="280" y2="400" stroke="#4169E1" stroke-width="3"/>
<text x="50%" y="550" font-family="微软雅黑" font-size="16" fill="#1E90FF" text-anchor="middle">探索 • 创新 • 进步</text>
</svg>
\`\`\`
`

View File

@@ -0,0 +1,136 @@
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
export const mockedWorkflowProcess = {
status: WorkflowRunningStatus.Succeeded,
resultText: 'Hello, how can I assist you today?',
tracing: [
{
extras: {},
id: 'f6337dc9-e280-4915-965f-10b0552dd917',
node_id: '1724232060789',
node_type: 'start',
title: 'Start',
index: 1,
predecessor_node_id: null,
inputs: {
'sys.query': 'hi',
'sys.files': [],
'sys.conversation_id': '92ce0a3e-8f15-43d1-b31d-32716c4b10a7',
'sys.user_id': 'fbff43f9-d5a4-4e85-b63b-d3a91d806c6f',
'sys.dialogue_count': 1,
'sys.app_id': 'b2e8906a-aad3-43a0-9ace-0e44cc7315e1',
'sys.workflow_id': '70004abe-561f-418b-b9e8-8c957ce55140',
'sys.workflow_run_id': '69db9267-aaee-42e1-9581-dbfb67e8eeb5',
},
process_data: null,
outputs: {
'sys.query': 'hi',
'sys.files': [],
'sys.conversation_id': '92ce0a3e-8f15-43d1-b31d-32716c4b10a7',
'sys.user_id': 'fbff43f9-d5a4-4e85-b63b-d3a91d806c6f',
'sys.dialogue_count': 1,
'sys.app_id': 'b2e8906a-aad3-43a0-9ace-0e44cc7315e1',
'sys.workflow_id': '70004abe-561f-418b-b9e8-8c957ce55140',
'sys.workflow_run_id': '69db9267-aaee-42e1-9581-dbfb67e8eeb5',
},
status: 'succeeded',
error: null,
elapsed_time: 0.035744,
execution_metadata: null,
created_at: 1728980002,
finished_at: 1728980002,
files: [],
parallel_id: null,
parallel_start_node_id: null,
parent_parallel_id: null,
parent_parallel_start_node_id: null,
iteration_id: null,
},
{
extras: {},
id: '92204d8d-4198-4c46-aa02-c2754b11dec9',
node_id: 'llm',
node_type: 'llm',
title: 'LLM',
index: 2,
predecessor_node_id: '1724232060789',
inputs: null,
process_data: {
model_mode: 'chat',
prompts: [
{
role: 'system',
text: 'hi',
files: [],
},
{
role: 'user',
text: 'hi',
files: [],
},
],
model_provider: 'openai',
model_name: 'gpt-4o-mini',
},
outputs: {
text: 'Hello! How can I assist you today?',
usage: {
prompt_tokens: 13,
prompt_unit_price: '0.15',
prompt_price_unit: '0.000001',
prompt_price: '0.0000020',
completion_tokens: 9,
completion_unit_price: '0.60',
completion_price_unit: '0.000001',
completion_price: '0.0000054',
total_tokens: 22,
total_price: '0.0000074',
currency: 'USD',
latency: 1.8902503330027685,
},
finish_reason: 'stop',
},
status: 'succeeded',
error: null,
elapsed_time: 5.089409,
execution_metadata: {
total_tokens: 22,
total_price: '0.0000074',
currency: 'USD',
},
created_at: 1728980002,
finished_at: 1728980007,
files: [],
parallel_id: null,
parallel_start_node_id: null,
parent_parallel_id: null,
parent_parallel_start_node_id: null,
iteration_id: null,
},
{
extras: {},
id: '7149bac6-60f9-4e06-a5ed-1d9d3764c06b',
node_id: 'answer',
node_type: 'answer',
title: 'Answer',
index: 3,
predecessor_node_id: 'llm',
inputs: null,
process_data: null,
outputs: {
answer: 'Hello! How can I assist you today?',
},
status: 'succeeded',
error: null,
elapsed_time: 0.015339,
execution_metadata: null,
created_at: 1728980007,
finished_at: 1728980007,
parallel_id: null,
parallel_start_node_id: null,
parent_parallel_id: null,
parent_parallel_start_node_id: null,
},
],
} as unknown as WorkflowProcess

View File

@@ -0,0 +1,59 @@
import type { FC } from 'react'
import { memo } from 'react'
import type {
ChatItem,
} from '../../types'
import { Markdown } from '@/app/components/base/markdown'
import Thought from '@/app/components/base/chat/chat/thought'
import { FileList } from '@/app/components/base/file-uploader'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
type AgentContentProps = {
item: ChatItem
responding?: boolean
}
const AgentContent: FC<AgentContentProps> = ({
item,
responding,
}) => {
const {
annotation,
agent_thoughts,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
return (
<div>
{agent_thoughts?.map((thought, index) => (
<div key={index} className='px-2 py-1'>
{thought.thought && (
<Markdown content={thought.thought} />
)}
{/* {item.tool} */}
{/* perhaps not use tool */}
{!!thought.tool && (
<Thought
thought={thought}
isFinished={!!thought.observation || !responding}
/>
)}
{
!!thought.message_files?.length && (
<FileList
files={getProcessedFilesFromResponse(thought.message_files.map((item: any) => ({ ...item, related_id: item.id })))}
showDeleteAction={false}
showDownloadAction={true}
canPreview={true}
/>
)
}
</div>
))}
</div>
)
}
export default memo(AgentContent)

View File

@@ -0,0 +1,31 @@
import type { FC } from 'react'
import { memo } from 'react'
import type { ChatItem } from '../../types'
import { Markdown } from '@/app/components/base/markdown'
import cn from '@/utils/classnames'
type BasicContentProps = {
item: ChatItem
}
const BasicContent: FC<BasicContentProps> = ({
item,
}) => {
const {
annotation,
content,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
return (
<Markdown
className={cn(
item.isError && '!text-[#F04438]',
)}
content={content}
/>
)
}
export default memo(BasicContent)

View File

@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/react'
import type { ChatItem } from '../../types'
import { mockedWorkflowProcess } from './__mocks__/workflowProcess'
import { markdownContent } from './__mocks__/markdownContent'
import { markdownContentSVG } from './__mocks__/markdownContentSVG'
import Answer from '.'
const meta = {
title: 'Base/Chat Answer',
component: Answer,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
argTypes: {
noChatInput: { control: 'boolean', description: 'If set to true, some buttons that are supposed to be shown on hover will not be displayed.' },
responding: { control: 'boolean', description: 'Indicates if the answer is being generated.' },
showPromptLog: { control: 'boolean', description: 'If set to true, the prompt log button will be shown on hover.' },
},
args: {
noChatInput: false,
responding: false,
showPromptLog: false,
},
} satisfies Meta<typeof Answer>
export default meta
type Story = StoryObj<typeof meta>
const mockedBaseChatItem = {
id: '1',
isAnswer: true,
content: 'Hello, how can I assist you today?',
} satisfies ChatItem
export const Basic: Story = {
args: {
item: mockedBaseChatItem,
question: mockedBaseChatItem.content,
index: 0,
},
render: (args) => {
return <div className="w-full px-10 py-5">
<Answer {...args} />
</div>
},
}
export const WithWorkflowProcess: Story = {
args: {
item: {
...mockedBaseChatItem,
workflowProcess: mockedWorkflowProcess,
},
question: mockedBaseChatItem.content,
index: 0,
},
render: (args) => {
return <div className="w-full px-10 py-5">
<Answer {...args} />
</div>
},
}
export const WithMarkdownContent: Story = {
args: {
item: {
...mockedBaseChatItem,
content: markdownContent,
},
question: mockedBaseChatItem.content,
index: 0,
},
render: (args) => {
return <div className="w-full px-10 py-5">
<Answer {...args} />
</div>
},
}
export const WithMarkdownSVG: Story = {
args: {
item: {
...mockedBaseChatItem,
content: markdownContentSVG,
},
question: mockedBaseChatItem.content,
index: 0,
},
render: (args) => {
return <div className="w-full px-10 py-5">
<Answer {...args} />
</div>
},
}

View File

@@ -0,0 +1,235 @@
import type {
FC,
ReactNode,
} from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
ChatConfig,
ChatItem,
} from '../../types'
import Operation from './operation'
import AgentContent from './agent-content'
import BasicContent from './basic-content'
import SuggestedQuestions from './suggested-questions'
import More from './more'
import WorkflowProcessItem from './workflow-process'
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import Citation from '@/app/components/base/chat/chat/citation'
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
import type { AppData } from '@/models/share'
import AnswerIcon from '@/app/components/base/answer-icon'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import cn from '@/utils/classnames'
import { FileList } from '@/app/components/base/file-uploader'
type AnswerProps = {
item: ChatItem
question: string
index: number
config?: ChatConfig
answerIcon?: ReactNode
responding?: boolean
showPromptLog?: boolean
chatAnswerContainerInner?: string
hideProcessDetail?: boolean
appData?: AppData
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
}
const Answer: FC<AnswerProps> = ({
item,
question,
index,
config,
answerIcon,
responding,
showPromptLog,
chatAnswerContainerInner,
hideProcessDetail,
appData,
noChatInput,
switchSibling,
}) => {
const { t } = useTranslation()
const {
content,
citation,
agent_thoughts,
more,
annotation,
workflowProcess,
allFiles,
message_files,
} = item
const hasAgentThoughts = !!agent_thoughts?.length
const [containerWidth, setContainerWidth] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const getContainerWidth = () => {
if (containerRef.current)
setContainerWidth(containerRef.current?.clientWidth + 16)
}
useEffect(() => {
getContainerWidth()
}, [])
const getContentWidth = () => {
if (contentRef.current)
setContentWidth(contentRef.current?.clientWidth)
}
useEffect(() => {
if (!responding)
getContentWidth()
}, [responding])
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
useEffect(() => {
if (!containerRef.current)
return
const resizeObserver = new ResizeObserver(() => {
getContentWidth()
})
resizeObserver.observe(containerRef.current)
return () => {
resizeObserver.disconnect()
}
}, [])
return (
<div className='flex mb-2 last:mb-0'>
<div className='shrink-0 relative w-10 h-10'>
{answerIcon || <AnswerIcon />}
{responding && (
<div className='absolute -top-[3px] -left-[3px] pl-[6px] flex items-center w-4 h-4 bg-white rounded-full shadow-xs border-[0.5px] border-gray-50'>
<LoadingAnim type='avatar' />
</div>
)}
</div>
<div className='chat-answer-container group grow w-0 ml-4' ref={containerRef}>
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={contentRef}
className={cn('relative inline-block px-4 py-3 max-w-full bg-chat-bubble-bg rounded-2xl body-lg-regular text-text-primary', workflowProcess && 'w-full')}
>
{
!responding && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render the normal steps */}
{
workflowProcess && !hideProcessDetail && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
/>
)
}
{/** Hide workflow steps by it's settings in siteInfo */}
{
workflowProcess && hideProcessDetail && appData && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={!appData.site.show_workflow_steps}
/>
)
}
{
responding && !content && !hasAgentThoughts && (
<div className='flex items-center justify-center w-6 h-5'>
<LoadingAnim type='text' />
</div>
)
}
{
content && !hasAgentThoughts && (
<BasicContent item={item} />
)
}
{
hasAgentThoughts && (
<AgentContent
item={item}
responding={responding}
/>
)
}
{
!!allFiles?.length && (
<FileList
className='my-1'
files={allFiles}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
!!message_files?.length && (
<FileList
className='my-1'
files={message_files}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
annotation?.id && annotation.authorName && (
<EditTitle
className='mt-1'
title={t('appAnnotation.editBy', { author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && !responding && (
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
)
}
{item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && <div className="pt-3.5 flex justify-center items-center text-sm">
<button
className={`${item.prevSibling ? 'opacity-100' : 'opacity-30'}`}
disabled={!item.prevSibling}
onClick={() => item.prevSibling && switchSibling?.(item.prevSibling)}
>
<ChevronRight className="w-[14px] h-[14px] rotate-180 text-text-primary" />
</button>
<span className="px-2 text-xs text-text-primary">{item.siblingIndex + 1} / {item.siblingCount}</span>
<button
className={`${item.nextSibling ? 'opacity-100' : 'opacity-30'}`}
disabled={!item.nextSibling}
onClick={() => item.nextSibling && switchSibling?.(item.nextSibling)}
>
<ChevronRight className="w-[14px] h-[14px] text-text-primary" />
</button>
</div>}
</div>
</div>
<More more={more} />
</div>
</div>
)
}
export default memo(Answer)

View File

@@ -0,0 +1,46 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChatItem } from '../../types'
import { formatNumber } from '@/utils/format'
type MoreProps = {
more: ChatItem['more']
}
const More: FC<MoreProps> = ({
more,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center mt-1 h-[18px] text-xs text-gray-400 opacity-0 group-hover:opacity-100'>
{
more && (
<>
<div
className='mr-2 shrink-0 truncate max-w-[33.3%]'
title={`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
>
{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
</div>
<div
className='shrink-0 truncate max-w-[33.3%]'
title={`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
>
{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
</div>
<div className='shrink-0 mx-2'>·</div>
<div
className='shrink-0 truncate max-w-[33.3%]'
title={more.time}
>
{more.time}
</div>
</>
)
}
</div>
)
}
export default memo(More)

View File

@@ -0,0 +1,238 @@
import type { FC } from 'react'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import type { ChatItem } from '../../types'
import { useChatContext } from '../context'
import RegenerateBtn from '@/app/components/base/regenerate-btn'
import cn from '@/utils/classnames'
import CopyBtn from '@/app/components/base/copy-btn'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
import AudioBtn from '@/app/components/base/audio-btn'
import AnnotationCtrlBtn from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-btn'
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
import {
ThumbsDown,
ThumbsUp,
} from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import Log from '@/app/components/base/chat/chat/log'
type OperationProps = {
item: ChatItem
question: string
index: number
showPromptLog?: boolean
maxSize: number
contentWidth: number
hasWorkflowProcess: boolean
noChatInput?: boolean
}
const Operation: FC<OperationProps> = ({
item,
question,
index,
showPromptLog,
maxSize,
contentWidth,
hasWorkflowProcess,
noChatInput,
}) => {
const { t } = useTranslation()
const {
config,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
onFeedback,
onRegenerate,
} = useChatContext()
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
const {
id,
isOpeningStatement,
content: messageContent,
annotation,
feedback,
adminFeedback,
agent_thoughts,
} = item
const hasAnnotation = !!annotation?.id
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
const content = useMemo(() => {
if (agent_thoughts?.length)
return agent_thoughts.reduce((acc, cur) => acc + cur.thought, '')
return messageContent
}, [agent_thoughts, messageContent])
const handleFeedback = async (rating: 'like' | 'dislike' | null) => {
if (!config?.supportFeedback || !onFeedback)
return
await onFeedback?.(id, { rating })
setLocalFeedback({ rating })
}
const operationWidth = useMemo(() => {
let width = 0
if (!isOpeningStatement)
width += 28
if (!isOpeningStatement && showPromptLog)
width += 102 + 8
if (!isOpeningStatement && config?.text_to_speech?.enabled)
width += 33
if (!isOpeningStatement && config?.supportAnnotation && config?.annotation_reply?.enabled)
width += 56 + 8
if (config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement)
width += 60 + 8
if (config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement)
width += 28 + 8
return width
}, [isOpeningStatement, showPromptLog, config?.text_to_speech?.enabled, config?.supportAnnotation, config?.annotation_reply?.enabled, config?.supportFeedback, localFeedback?.rating, onFeedback])
const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize])
return (
<>
<div
className={cn(
'absolute flex justify-end gap-1',
hasWorkflowProcess && '-top-3.5 -right-3.5',
!positionRight && '-top-3.5 -right-3.5',
!hasWorkflowProcess && positionRight && '!top-[9px]',
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
>
{!isOpeningStatement && (
<CopyBtn
value={content}
className='hidden group-hover:block'
/>
)}
{!isOpeningStatement && (showPromptLog || config?.text_to_speech?.enabled) && (
<div className='hidden group-hover:flex items-center w-max h-[28px] p-0.5 rounded-lg bg-white border-[0.5px] border-gray-100 shadow-md shrink-0'>
{showPromptLog && (
<>
<Log logItem={item} />
<div className='mx-1 w-[1px] h-[14px] bg-gray-200' />
</>
)}
{(config?.text_to_speech?.enabled) && (
<>
<AudioBtn
id={id}
value={content}
noCache={false}
voice={config?.text_to_speech?.voice}
className='hidden group-hover:block'
/>
</>
)}
</div>
)}
{(!isOpeningStatement && config?.supportAnnotation && config.annotation_reply?.enabled) && (
<AnnotationCtrlBtn
appId={config?.appId || ''}
messageId={id}
annotationId={annotation?.id || ''}
className='hidden group-hover:block ml-1 shrink-0'
cached={hasAnnotation}
query={question}
answer={content}
onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)}
onEdit={() => setIsShowReplyModal(true)}
onRemoved={() => onAnnotationRemoved?.(index)}
/>
)}
{
annotation?.id && (
<div
className='relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-[#444CE7] shadow-md group-hover:hidden'
>
<div className='p-1 rounded-lg bg-[#EEF4FF] '>
<MessageFast className='w-4 h-4' />
</div>
</div>
)
}
{
!isOpeningStatement && !noChatInput && <RegenerateBtn className='hidden group-hover:block mr-1' onClick={() => onRegenerate?.(item)} />
}
{
config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement && (
<div className='hidden group-hover:flex shrink-0 items-center px-0.5 bg-white border-[0.5px] border-gray-100 shadow-md text-gray-500 rounded-lg'>
<Tooltip popupContent={t('appDebug.operation.agree')}>
<div
className='flex items-center justify-center mr-0.5 w-6 h-6 rounded-md hover:bg-black/5 hover:text-gray-800 cursor-pointer'
onClick={() => handleFeedback('like')}
>
<ThumbsUp className='w-4 h-4' />
</div>
</Tooltip>
<Tooltip
popupContent={t('appDebug.operation.disagree')}
>
<div
className='flex items-center justify-center w-6 h-6 rounded-md hover:bg-black/5 hover:text-gray-800 cursor-pointer'
onClick={() => handleFeedback('dislike')}
>
<ThumbsDown className='w-4 h-4' />
</div>
</Tooltip>
</div>
)
}
{
config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement && (
<Tooltip
popupContent={localFeedback.rating === 'like' ? t('appDebug.operation.cancelAgree') : t('appDebug.operation.cancelDisagree')}
>
<div
className={`
flex items-center justify-center w-7 h-7 rounded-[10px] border-[2px] border-white cursor-pointer
${localFeedback.rating === 'like' && 'bg-blue-50 text-blue-600'}
${localFeedback.rating === 'dislike' && 'bg-red-100 text-red-600'}
`}
onClick={() => handleFeedback(null)}
>
{
localFeedback.rating === 'like' && (
<ThumbsUp className='w-4 h-4' />
)
}
{
localFeedback.rating === 'dislike' && (
<ThumbsDown className='w-4 h-4' />
)
}
</div>
</Tooltip>
)
}
</div>
<EditReplyModal
isShow={isShowReplyModal}
onHide={() => setIsShowReplyModal(false)}
query={question}
answer={content}
onEdited={(editedQuery, editedAnswer) => onAnnotationEdited?.(editedQuery, editedAnswer, index)}
onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)}
appId={config?.appId || ''}
messageId={id}
annotationId={annotation?.id || ''}
createdAt={annotation?.created_at}
onRemove={() => onAnnotationRemoved?.(index)}
/>
</>
)
}
export default memo(Operation)

View File

@@ -0,0 +1,36 @@
import type { FC } from 'react'
import { memo } from 'react'
import type { ChatItem } from '../../types'
import { useChatContext } from '../context'
type SuggestedQuestionsProps = {
item: ChatItem
}
const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
item,
}) => {
const { onSend } = useChatContext()
const {
isOpeningStatement,
suggestedQuestions,
} = item
if (!isOpeningStatement || !suggestedQuestions?.length)
return null
return (
<div className='flex flex-wrap'>
{suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
<div
key={index}
className='mt-1 mr-1 max-w-full last:mr-0 shrink-0 py-[5px] leading-[18px] items-center px-4 rounded-lg border border-gray-200 shadow-xs bg-white text-xs font-medium text-primary-600 cursor-pointer'
onClick={() => onSend?.(question)}
>
{question}
</div>),
)}
</div>
)
}
export default memo(SuggestedQuestions)

View File

@@ -0,0 +1,71 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiHammerFill,
RiLoader2Line,
} from '@remixicon/react'
import type { ToolInfoInThought } from '../type'
import cn from '@/utils/classnames'
type ToolDetailProps = {
payload: ToolInfoInThought
}
const ToolDetail = ({
payload,
}: ToolDetailProps) => {
const { t } = useTranslation()
const { name, label, input, isFinished, output } = payload
const toolLabel = name.startsWith('dataset_') ? t('dataset.knowledge') : label
const [expand, setExpand] = useState(false)
return (
<div
className={cn(
'rounded-xl',
!expand && 'border-l-[0.25px] border-components-panel-border bg-workflow-process-bg',
expand && 'border-[0.5px] border-components-panel-border-subtle bg-background-section-burn',
)}
>
<div
className={cn(
'flex items-center system-xs-medium text-text-tertiary px-2.5 py-2 cursor-pointer',
expand && 'pb-1.5',
)}
onClick={() => setExpand(!expand)}
>
{isFinished && <RiHammerFill className='mr-1 w-3.5 h-3.5' />}
{!isFinished && <RiLoader2Line className='mr-1 w-3.5 h-3.5 animate-spin' />}
{t(`tools.thought.${isFinished ? 'used' : 'using'}`)}
<div className='mx-1 text-text-secondary'>{toolLabel}</div>
{!expand && <RiArrowRightSLine className='w-4 h-4' />}
{expand && <RiArrowDownSLine className='ml-auto w-4 h-4' />}
</div>
{
expand && (
<>
<div className='mb-0.5 mx-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
<div className='flex items-center justify-between px-2 pt-1 h-7 system-xs-semibold-uppercase'>
{t('tools.thought.requestTitle')}
</div>
<div className='pt-1 px-3 pb-2 code-xs-regular break-words'>
{input}
</div>
</div>
<div className='mx-1 mb-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
<div className='flex items-center justify-between px-2 pt-1 h-7 system-xs-semibold-uppercase'>
{t('tools.thought.responseTitle')}
</div>
<div className='pt-1 px-3 pb-2 code-xs-regular break-words'>
{output}
</div>
</div>
</>
)
}
</div>
)
}
export default ToolDetail

View File

@@ -0,0 +1,128 @@
import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import {
RiArrowRightSLine,
RiErrorWarningFill,
RiLoader2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { ChatItem, WorkflowProcess } from '../../types'
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
import cn from '@/utils/classnames'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useStore as useAppStore } from '@/app/components/app/store'
type WorkflowProcessProps = {
data: WorkflowProcess
item?: ChatItem
expand?: boolean
hideInfo?: boolean
hideProcessDetail?: boolean
readonly?: boolean
}
const WorkflowProcessItem = ({
data,
item,
expand = false,
hideInfo = false,
hideProcessDetail = false,
readonly = false,
}: WorkflowProcessProps) => {
const { t } = useTranslation()
const [collapse, setCollapse] = useState(!expand)
const running = data.status === WorkflowRunningStatus.Running
const succeeded = data.status === WorkflowRunningStatus.Succeeded
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
const background = useMemo(() => {
if (collapse)
return 'linear-gradient(90deg, rgba(200, 206, 218, 0.20) 0%, rgba(200, 206, 218, 0.04) 100%)'
if (running && !collapse)
return 'linear-gradient(180deg, #E1E4EA 0%, #EAECF0 100%)'
if (succeeded && !collapse)
return 'linear-gradient(180deg, #ECFDF3 0%, #F6FEF9 100%)'
if (failed && !collapse)
return 'linear-gradient(180deg, #FEE4E2 0%, #FEF3F2 100%)'
}, [running, succeeded, failed, collapse])
useEffect(() => {
setCollapse(!expand)
}, [expand])
const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem)
const setShowMessageLogModal = useAppStore(s => s.setShowMessageLogModal)
const setCurrentLogModalActiveTab = useAppStore(s => s.setCurrentLogModalActiveTab)
const showIterationDetail = useCallback(() => {
setCurrentLogItem(item)
setCurrentLogModalActiveTab('TRACING')
setShowMessageLogModal(true)
}, [item, setCurrentLogItem, setCurrentLogModalActiveTab, setShowMessageLogModal])
const showRetryDetail = useCallback(() => {
setCurrentLogItem(item)
setCurrentLogModalActiveTab('TRACING')
setShowMessageLogModal(true)
}, [item, setCurrentLogItem, setCurrentLogModalActiveTab, setShowMessageLogModal])
return (
<div
className={cn(
'-mx-1 px-2.5 rounded-xl border-[0.5px]',
collapse ? 'py-[7px] border-components-panel-border' : 'pt-[7px] px-1 pb-1 border-components-panel-border-subtle',
)}
style={{
background,
}}
>
<div
className={cn('flex items-center cursor-pointer', !collapse && 'px-1.5', readonly && 'cursor-default')}
onClick={() => !readonly && setCollapse(!collapse)}
>
{
running && (
<RiLoader2Line className='shrink-0 mr-1 w-3.5 h-3.5 text-text-tertiary' />
)
}
{
succeeded && (
<CheckCircle className='shrink-0 mr-1 w-3.5 h-3.5 text-text-success' />
)
}
{
failed && (
<RiErrorWarningFill className='shrink-0 mr-1 w-3.5 h-3.5 text-text-destructive' />
)
}
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
{t('workflow.common.workflowProcess')}
</div>
{!readonly && <RiArrowRightSLine className={`'ml-1 w-4 h-4 text-text-tertiary' ${collapse ? '' : 'rotate-90'}`} />}
</div>
{
!collapse && !readonly && (
<div className='mt-1.5'>
{
<TracingPanel
list={data.tracing}
onShowIterationDetail={showIterationDetail}
onShowRetryDetail={showRetryDetail}
hideNodeInfo={hideInfo}
hideNodeProcessDetail={hideProcessDetail}
/>
}
</div>
)
}
</div>
)
}
export default WorkflowProcessItem

View File

@@ -0,0 +1,47 @@
import {
useCallback,
useRef,
useState,
} from 'react'
import type { TextAreaRef } from 'rc-textarea'
export const useTextAreaHeight = () => {
const wrapperRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<TextAreaRef>(null)
const textValueRef = useRef<HTMLDivElement>(null)
const holdSpaceRef = useRef<HTMLDivElement>(null)
const [isMultipleLine, setIsMultipleLine] = useState(false)
const handleComputeHeight = useCallback(() => {
const textareaElement = textareaRef.current?.resizableTextArea.textArea
if (wrapperRef.current && textareaElement && textValueRef.current && holdSpaceRef.current) {
const { width: wrapperWidth } = wrapperRef.current.getBoundingClientRect()
const { height: textareaHeight } = textareaElement.getBoundingClientRect()
const { width: textValueWidth } = textValueRef.current.getBoundingClientRect()
const { width: holdSpaceWidth } = holdSpaceRef.current.getBoundingClientRect()
if (textareaHeight > 32) {
setIsMultipleLine(true)
}
else {
if (textValueWidth + holdSpaceWidth >= wrapperWidth)
setIsMultipleLine(true)
else
setIsMultipleLine(false)
}
}
}, [])
const handleTextareaResize = useCallback(() => {
handleComputeHeight()
}, [handleComputeHeight])
return {
wrapperRef,
textareaRef,
textValueRef,
holdSpaceRef,
handleTextareaResize,
isMultipleLine,
}
}

View File

@@ -0,0 +1,227 @@
import {
useCallback,
useRef,
useState,
} from 'react'
import Textarea from 'rc-textarea'
import { useTranslation } from 'react-i18next'
import Recorder from 'js-audio-recorder'
import type {
EnableType,
OnSend,
} from '../../types'
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
import type { InputForm } from '../type'
import { useCheckInputsForms } from '../check-input-forms-hooks'
import { useTextAreaHeight } from './hooks'
import Operation from './operation'
import cn from '@/utils/classnames'
import { FileListInChatInput } from '@/app/components/base/file-uploader'
import { useFile } from '@/app/components/base/file-uploader/hooks'
import {
FileContextProvider,
useFileStore,
} from '@/app/components/base/file-uploader/store'
import VoiceInput from '@/app/components/base/voice-input'
import { useToastContext } from '@/app/components/base/toast'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import type { FileUpload } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
type ChatInputAreaProps = {
showFeatureBar?: boolean
showFileUpload?: boolean
featureBarDisabled?: boolean
onFeatureBarClick?: (state: boolean) => void
visionConfig?: FileUpload
speechToTextConfig?: EnableType
onSend?: OnSend
inputs?: Record<string, any>
inputsForm?: InputForm[]
theme?: Theme | null
isResponding?: boolean
}
const ChatInputArea = ({
showFeatureBar,
showFileUpload,
featureBarDisabled,
onFeatureBarClick,
visionConfig,
speechToTextConfig = { enabled: true },
onSend,
inputs = {},
inputsForm = [],
theme,
isResponding,
}: ChatInputAreaProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const {
wrapperRef,
textareaRef,
textValueRef,
holdSpaceRef,
handleTextareaResize,
isMultipleLine,
} = useTextAreaHeight()
const [query, setQuery] = useState('')
const [showVoiceInput, setShowVoiceInput] = useState(false)
const filesStore = useFileStore()
const {
handleDragFileEnter,
handleDragFileLeave,
handleDragFileOver,
handleDropFile,
handleClipboardPasteFile,
isDragActive,
} = useFile(visionConfig!)
const { checkInputsForm } = useCheckInputsForms()
const historyRef = useRef([''])
const [currentIndex, setCurrentIndex] = useState(-1)
const handleSend = () => {
if (isResponding) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return
}
if (onSend) {
const { files, setFiles } = filesStore.getState()
if (files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
return
}
if (!query || !query.trim()) {
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
return
}
if (checkInputsForm(inputs, inputsForm)) {
onSend(query, files)
setQuery('')
setFiles([])
}
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
setQuery(query.replace(/\n$/, ''))
historyRef.current.push(query)
setCurrentIndex(historyRef.current.length)
handleSend()
}
else if (e.key === 'ArrowUp' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) {
// When the cmd + up key is pressed, output the previous element
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
setQuery(historyRef.current[currentIndex - 1])
}
}
else if (e.key === 'ArrowDown' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) {
// When the cmd + down key is pressed, output the next element
if (currentIndex < historyRef.current.length - 1) {
setCurrentIndex(currentIndex + 1)
setQuery(historyRef.current[currentIndex + 1])
}
else if (currentIndex === historyRef.current.length - 1) {
// If it is the last element, clear the input box
setCurrentIndex(historyRef.current.length)
setQuery('')
}
}
}
const handleShowVoiceInput = useCallback(() => {
(Recorder as any).getPermission().then(() => {
setShowVoiceInput(true)
}, () => {
notify({ type: 'error', message: t('common.voiceInput.notAllow') })
})
}, [t, notify])
const operation = (
<Operation
ref={holdSpaceRef}
fileConfig={visionConfig}
speechToTextConfig={speechToTextConfig}
onShowVoiceInput={handleShowVoiceInput}
onSend={handleSend}
theme={theme}
/>
)
return (
<>
<div
className={cn(
'relative pb-[9px] bg-components-panel-bg-blur border border-components-chat-input-border rounded-xl shadow-md z-10',
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
)}
>
<div className='relative px-[9px] pt-[9px] max-h-[158px] overflow-x-hidden overflow-y-auto'>
<FileListInChatInput fileConfig={visionConfig!} />
<div
ref={wrapperRef}
className='flex items-center justify-between'
>
<div className='flex items-center relative grow w-full'>
<div
ref={textValueRef}
className='absolute w-auto h-auto p-1 leading-6 body-lg-regular pointer-events-none whitespace-pre invisible'
>
{query}
</div>
<Textarea
ref={textareaRef}
className={cn(
'p-1 w-full leading-6 body-lg-regular text-text-tertiary outline-none',
)}
placeholder={t('common.chat.inputPlaceholder') || ''}
autoFocus
autoSize={{ minRows: 1 }}
onResize={handleTextareaResize}
value={query}
onChange={(e) => {
setQuery(e.target.value)
handleTextareaResize()
}}
onKeyDown={handleKeyDown}
onPaste={handleClipboardPasteFile}
onDragEnter={handleDragFileEnter}
onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver}
onDrop={handleDropFile}
/>
</div>
{
!isMultipleLine && operation
}
</div>
{
showVoiceInput && (
<VoiceInput
onCancel={() => setShowVoiceInput(false)}
onConverted={text => setQuery(text)}
/>
)
}
</div>
{
isMultipleLine && (
<div className='px-[9px]'>{operation}</div>
)
}
</div>
{showFeatureBar && <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={onFeatureBarClick} />}
</>
)
}
const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
return (
<FileContextProvider>
<ChatInputArea {...props} />
</FileContextProvider>
)
}
export default ChatInputAreaWrapper

View File

@@ -0,0 +1,76 @@
import {
forwardRef,
memo,
} from 'react'
import {
RiMicLine,
RiSendPlane2Fill,
} from '@remixicon/react'
import type {
EnableType,
} from '../../types'
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
import Button from '@/app/components/base/button'
import ActionButton from '@/app/components/base/action-button'
import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
import type { FileUpload } from '@/app/components/base/features/types'
import cn from '@/utils/classnames'
type OperationProps = {
fileConfig?: FileUpload
speechToTextConfig?: EnableType
onShowVoiceInput?: () => void
onSend: () => void
theme?: Theme | null
}
const Operation = forwardRef<HTMLDivElement, OperationProps>(({
fileConfig,
speechToTextConfig,
onShowVoiceInput,
onSend,
theme,
}, ref) => {
return (
<div
className={cn(
'shrink-0 flex items-center justify-end',
)}
>
<div
className='flex items-center pl-1'
ref={ref}
>
<div className='flex items-center space-x-1'>
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />}
{
speechToTextConfig?.enabled && (
<ActionButton
size='l'
onClick={onShowVoiceInput}
>
<RiMicLine className='w-5 h-5' />
</ActionButton>
)
}
</div>
<Button
className='ml-3 px-0 w-8'
variant='primary'
onClick={onSend}
style={
theme
? {
backgroundColor: theme.primaryColor,
}
: {}
}
>
<RiSendPlane2Fill className='w-4 h-4' />
</Button>
</div>
</div>
)
})
Operation.displayName = 'Operation'
export default memo(Operation)

View File

@@ -0,0 +1,54 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import type { InputForm } from './type'
import { useToastContext } from '@/app/components/base/toast'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
export const useCheckInputsForms = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => {
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForm.filter(({ required }) => required)
if (requiredVars?.length) {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!inputs[variable])
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputs[variable]) {
const files = inputs[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
}, [notify, t])
return {
checkInputsForm,
}
}

View File

@@ -0,0 +1,125 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import type { CitationItem } from '../type'
import Popup from './popup'
export type Resources = {
documentId: string
documentName: string
dataSourceType: string
sources: CitationItem[]
}
type CitationProps = {
data: CitationItem[]
showHitInfo?: boolean
containerClassName?: string
}
const Citation: FC<CitationProps> = ({
data,
showHitInfo,
containerClassName = 'chat-answer-container',
}) => {
const { t } = useTranslation()
const elesRef = useRef<HTMLDivElement[]>([])
const [limitNumberInOneLine, setLimitNumberInOneLine] = useState(0)
const [showMore, setShowMore] = useState(false)
const resources = useMemo(() => data.reduce((prev: Resources[], next) => {
const documentId = next.document_id
const documentName = next.document_name
const dataSourceType = next.data_source_type
const documentIndex = prev.findIndex(i => i.documentId === documentId)
if (documentIndex > -1) {
prev[documentIndex].sources.push(next)
}
else {
prev.push({
documentId,
documentName,
dataSourceType,
sources: [next],
})
}
return prev
}, []), [data])
const handleAdjustResourcesLayout = () => {
const containerWidth = document.querySelector(`.${containerClassName}`)!.clientWidth - 40
let totalWidth = 0
for (let i = 0; i < resources.length; i++) {
totalWidth += elesRef.current[i].clientWidth
if (totalWidth + i * 4 > containerWidth!) {
totalWidth -= elesRef.current[i].clientWidth
if (totalWidth + 34 > containerWidth!)
setLimitNumberInOneLine(i - 1)
else
setLimitNumberInOneLine(i)
break
}
else {
setLimitNumberInOneLine(i + 1)
}
}
}
useEffect(() => {
handleAdjustResourcesLayout()
}, [])
const resourcesLength = resources.length
return (
<div className='mt-3 -mb-1'>
<div className='flex items-center mb-2 text-xs font-medium text-gray-500'>
{t('common.chat.citation.title')}
<div className='grow ml-2 h-[1px] bg-black/5' />
</div>
<div className='relative flex flex-wrap'>
{
resources.map((res, index) => (
<div
key={index}
className='absolute top-0 left-0 w-auto mr-1 mb-1 pl-7 pr-2 max-w-[240px] h-7 text-xs whitespace-nowrap opacity-0 -z-10'
ref={ele => (elesRef.current[index] = ele!)}
>
{res.documentName}
</div>
))
}
{
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map((res, index) => (
<div key={index} className='mr-1 mb-1 cursor-pointer'>
<Popup
data={res}
showHitInfo={showHitInfo}
/>
</div>
))
}
{
limitNumberInOneLine < resourcesLength && (
<div
className='flex items-center px-2 h-7 bg-white rounded-lg text-xs font-medium text-gray-500 cursor-pointer'
onClick={() => setShowMore(v => !v)}
>
{
!showMore
? `+ ${resourcesLength - limitNumberInOneLine}`
: <RiArrowDownSLine className='w-4 h-4 text-gray-600 rotate-180' />
}
</div>
)
}
</div>
</div>
)
}
export default Citation

View File

@@ -0,0 +1,131 @@
import { Fragment, useState } from 'react'
import type { FC } from 'react'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import Tooltip from './tooltip'
import ProgressTooltip from './progress-tooltip'
import type { Resources } from './index'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import FileIcon from '@/app/components/base/file-icon'
import {
Hash02,
Target04,
} from '@/app/components/base/icons/src/vender/line/general'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import {
BezierCurve03,
TypeSquare,
} from '@/app/components/base/icons/src/vender/line/editor'
type PopupProps = {
data: Resources
showHitInfo?: boolean
}
const Popup: FC<PopupProps> = ({
data,
showHitInfo = false,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const fileType = data.dataSourceType !== 'notion'
? (/\.([^.]*)$/g.exec(data.documentName)?.[1] || '')
: 'notion'
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
offset={{
mainAxis: 8,
crossAxis: -2,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className='flex items-center px-2 max-w-[240px] h-7 bg-white rounded-lg'>
<FileIcon type={fileType} className='shrink-0 mr-1 w-4 h-4' />
<div className='text-xs text-gray-600 truncate'>{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className='max-w-[360px] bg-gray-50 rounded-xl shadow-lg'>
<div className='px-4 pt-3 pb-2'>
<div className='flex items-center h-[18px]'>
<FileIcon type={fileType} className='shrink-0 mr-1 w-4 h-4' />
<div className='text-xs font-medium text-gray-600 truncate'>{data.documentName}</div>
</div>
</div>
<div className='px-4 py-0.5 max-h-[450px] bg-white rounded-lg overflow-y-auto'>
<div className='w-full'>
{
data.sources.map((source, index) => (
<Fragment key={index}>
<div className='group py-3'>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center px-1.5 h-5 border border-gray-200 rounded-md'>
<Hash02 className='mr-0.5 w-3 h-3 text-gray-400' />
<div className='text-[11px] font-medium text-gray-500'>
{source.segment_position || index + 1}
</div>
</div>
{
showHitInfo && (
<Link
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
className='hidden items-center h-[18px] text-xs text-primary-600 group-hover:flex'>
{t('common.chat.citation.linkToDataset')}
<ArrowUpRight className='ml-1 w-3 h-3' />
</Link>
)
}
</div>
<div className='text-[13px] text-gray-800 break-words'>{source.content}</div>
{
showHitInfo && (
<div className='flex items-center mt-2 text-xs font-medium text-gray-500 flex-wrap'>
<Tooltip
text={t('common.chat.citation.characters')}
data={source.word_count}
icon={<TypeSquare className='mr-1 w-3 h-3' />}
/>
<Tooltip
text={t('common.chat.citation.hitCount')}
data={source.hit_count}
icon={<Target04 className='mr-1 w-3 h-3' />}
/>
<Tooltip
text={t('common.chat.citation.vectorHash')}
data={source.index_node_hash?.substring(0, 7)}
icon={<BezierCurve03 className='mr-1 w-3 h-3' />}
/>
{
source.score && (
<ProgressTooltip data={Number(source.score.toFixed(2))} />
)
}
</div>
)
}
</div>
{
index !== data.sources.length - 1 && (
<div className='my-1 h-[1px] bg-black/5' />
)
}
</Fragment>
))
}
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Popup

View File

@@ -0,0 +1,46 @@
import { useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type ProgressTooltipProps = {
data: number
}
const ProgressTooltip: FC<ProgressTooltipProps> = ({
data,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
>
<PortalToFollowElemTrigger
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className='grow flex items-center'>
<div className='mr-1 w-16 h-1.5 rounded-[3px] border border-gray-400 overflow-hidden'>
<div className='bg-gray-400 h-full' style={{ width: `${data * 100}%` }}></div>
</div>
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className='p-3 bg-white text-xs font-medium text-gray-500 rounded-lg shadow-lg'>
{t('common.chat.citation.hitScore')} {data}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ProgressTooltip

View File

@@ -0,0 +1,46 @@
import React, { useState } from 'react'
import type { FC } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type TooltipProps = {
data: number | string
text: string
icon: React.ReactNode
}
const Tooltip: FC<TooltipProps> = ({
data,
text,
icon,
}) => {
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
>
<PortalToFollowElemTrigger
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className='flex items-center mr-6'>
{icon}
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className='p-3 bg-white text-xs font-medium text-gray-500 rounded-lg shadow-lg'>
{text} {data}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Tooltip

View File

@@ -0,0 +1,66 @@
'use client'
import type { ReactNode } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type { ChatProps } from './index'
export type ChatContextValue = Pick<ChatProps, 'config'
| 'isResponding'
| 'chatList'
| 'showPromptLog'
| 'questionIcon'
| 'answerIcon'
| 'onSend'
| 'onRegenerate'
| 'onAnnotationEdited'
| 'onAnnotationAdded'
| 'onAnnotationRemoved'
| 'onFeedback'
>
const ChatContext = createContext<ChatContextValue>({
chatList: [],
})
type ChatContextProviderProps = {
children: ReactNode
} & ChatContextValue
export const ChatContextProvider = ({
children,
config,
isResponding,
chatList,
showPromptLog,
questionIcon,
answerIcon,
onSend,
onRegenerate,
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
onFeedback,
}: ChatContextProviderProps) => {
return (
<ChatContext.Provider value={{
config,
isResponding,
chatList: chatList || [],
showPromptLog,
questionIcon,
answerIcon,
onSend,
onRegenerate,
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
onFeedback,
}}>
{children}
</ChatContext.Provider>
)
}
export const useChatContext = () => useContext(ChatContext)
export default ChatContext

View File

@@ -0,0 +1,663 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { produce, setAutoFreeze } from 'immer'
import { uniqBy } from 'lodash-es'
import { useParams, usePathname } from 'next/navigation'
import { v4 as uuidV4 } from 'uuid'
import type {
ChatConfig,
ChatItem,
ChatItemInTree,
Inputs,
} from '../types'
import { getThreadMessages } from '../utils'
import type { InputForm } from './type'
import {
getProcessedInputs,
processOpeningStatement,
} from './utils'
import { TransferMethod } from '@/types/app'
import { useToastContext } from '@/app/components/base/toast'
import { ssePost } from '@/service/base'
import type { Annotation } from '@/models/log'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import useTimestamp from '@/hooks/use-timestamp'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import {
getProcessedFiles,
getProcessedFilesFromResponse,
} from '@/app/components/base/file-uploader/utils'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
onConversationComplete?: (conversationId: string) => void
isPublicAPI?: boolean
}
export const useChat = (
config?: ChatConfig,
formSettings?: {
inputs: Inputs
inputsForm: InputForm[]
},
prevChatTree?: ChatItemInTree[],
stopChat?: (taskId: string) => void,
) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const { notify } = useToastContext()
const conversationId = useRef('')
const hasStopResponded = useRef(false)
const [isResponding, setIsResponding] = useState(false)
const isRespondingRef = useRef(false)
const taskIdRef = useRef('')
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
const conversationMessagesAbortControllerRef = useRef<AbortController | null>(null)
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
const params = useParams()
const pathname = usePathname()
const [chatTree, setChatTree] = useState<ChatItemInTree[]>(prevChatTree || [])
const chatTreeRef = useRef<ChatItemInTree[]>(chatTree)
const [targetMessageId, setTargetMessageId] = useState<string>()
const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId])
const getIntroduction = useCallback((str: string) => {
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
}, [formSettings?.inputs, formSettings?.inputsForm])
/** Final chat list that will be rendered */
const chatList = useMemo(() => {
const ret = [...threadMessages]
if (config?.opening_statement) {
const index = threadMessages.findIndex(item => item.isOpeningStatement)
if (index > -1) {
ret[index] = {
...ret[index],
content: getIntroduction(config.opening_statement),
suggestedQuestions: config.suggested_questions,
}
}
else {
ret.unshift({
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions,
})
}
}
return ret
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
useEffect(() => {
setAutoFreeze(false)
return () => {
setAutoFreeze(true)
}
}, [])
/** Find the target node by bfs and then operate on it */
const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => {
return produce(chatTreeRef.current, (draft) => {
const queue: ChatItemInTree[] = [...draft]
while (queue.length > 0) {
const current = queue.shift()!
if (current.id === targetId) {
operation(current)
break
}
if (current.children)
queue.push(...current.children)
}
})
}, [])
type UpdateChatTreeNode = {
(id: string, fields: Partial<ChatItemInTree>): void
(id: string, update: (node: ChatItemInTree) => void): void
}
const updateChatTreeNode: UpdateChatTreeNode = useCallback((
id: string,
fieldsOrUpdate: Partial<ChatItemInTree> | ((node: ChatItemInTree) => void),
) => {
const nextState = produceChatTreeNode(id, (node) => {
if (typeof fieldsOrUpdate === 'function') {
fieldsOrUpdate(node)
}
else {
Object.keys(fieldsOrUpdate).forEach((key) => {
(node as any)[key] = (fieldsOrUpdate as any)[key]
})
}
})
setChatTree(nextState)
chatTreeRef.current = nextState
}, [produceChatTreeNode])
const handleResponding = useCallback((isResponding: boolean) => {
setIsResponding(isResponding)
isRespondingRef.current = isResponding
}, [])
const handleStop = useCallback(() => {
hasStopResponded.current = true
handleResponding(false)
if (stopChat && taskIdRef.current)
stopChat(taskIdRef.current)
if (conversationMessagesAbortControllerRef.current)
conversationMessagesAbortControllerRef.current.abort()
if (suggestedQuestionsAbortControllerRef.current)
suggestedQuestionsAbortControllerRef.current.abort()
}, [stopChat, handleResponding])
const handleRestart = useCallback(() => {
conversationId.current = ''
taskIdRef.current = ''
handleStop()
setChatTree([])
setSuggestQuestions([])
}, [handleStop])
const updateCurrentQAOnTree = useCallback(({
parentId,
responseItem,
placeholderQuestionId,
questionItem,
}: {
parentId?: string
responseItem: ChatItem
placeholderQuestionId: string
questionItem: ChatItem
}) => {
let nextState: ChatItemInTree[]
const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] }
if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) {
// QA whose parent is not provided is considered as a first message of the conversation,
// and it should be a root node of the chat tree
nextState = produce(chatTree, (draft) => {
draft.push(currentQA)
})
}
else {
// find the target QA in the tree and update it; if not found, insert it to its parent node
nextState = produceChatTreeNode(parentId!, (parentNode) => {
const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id))
if (questionNodeIndex === -1)
parentNode.children!.push(currentQA)
else
parentNode.children![questionNodeIndex] = currentQA
})
}
setChatTree(nextState)
chatTreeRef.current = nextState
}, [chatTree, produceChatTreeNode])
const handleSend = useCallback(async (
url: string,
data: {
query: string
files?: FileEntity[]
parent_message_id?: string
[key: string]: any
},
{
onGetConversationMessages,
onGetSuggestedQuestions,
onConversationComplete,
isPublicAPI,
}: SendCallback,
) => {
setSuggestQuestions([])
if (isRespondingRef.current) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return false
}
const parentMessage = threadMessages.find(item => item.id === data.parent_message_id)
const placeholderQuestionId = `question-${Date.now()}`
const questionItem = {
id: placeholderQuestionId,
content: data.query,
isAnswer: false,
message_files: data.files,
parentMessageId: data.parent_message_id,
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
const placeholderAnswerItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
}
setTargetMessageId(parentMessage?.id)
updateCurrentQAOnTree({
parentId: data.parent_message_id,
responseItem: placeholderAnswerItem,
placeholderQuestionId,
questionItem,
})
// answer
const responseItem: ChatItemInTree = {
id: placeholderAnswerId,
content: '',
agent_thoughts: [],
message_files: [],
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
}
handleResponding(true)
hasStopResponded.current = false
const { query, files, inputs, ...restData } = data
const bodyParams = {
response_mode: 'streaming',
conversation_id: conversationId.current,
files: getProcessedFiles(files || []),
query,
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
...restData,
}
if (bodyParams?.files?.length) {
bodyParams.files = bodyParams.files.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
let isAgentMode = false
let hasSetResponseId = false
let ttsUrl = ''
let ttsIsPublic = false
if (params.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (params.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${params.appId}/text-to-audio`
else
ttsUrl = `/apps/${params.appId}/text-to-audio`
}
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {})
ssePost(
url,
{
body: bodyParams,
},
{
isPublicAPI,
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
if (!isAgentMode) {
responseItem.content = responseItem.content + message
}
else {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought)
lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
}
if (messageId && !hasSetResponseId) {
questionItem.id = `question-${messageId}`
responseItem.id = messageId
responseItem.parentMessageId = questionItem.id
hasSetResponseId = true
}
if (isFirstMessage && newConversationId)
conversationId.current = newConversationId
taskIdRef.current = taskId
if (messageId)
responseItem.id = messageId
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
async onCompleted(hasError?: boolean) {
handleResponding(false)
if (hasError)
return
if (onConversationComplete)
onConversationComplete(conversationId.current)
if (conversationId.current && !hasStopResponded.current && onGetConversationMessages) {
const { data }: any = await onGetConversationMessages(
conversationId.current,
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
)
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
if (!newResponseItem)
return
updateChatTreeNode(responseItem.id, {
content: newResponseItem.answer,
log: [
...newResponseItem.message,
...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant'
? [
{
role: 'assistant',
text: newResponseItem.answer,
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
},
]
: []),
],
more: {
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
latency: newResponseItem.provider_response_latency.toFixed(2),
},
// for agent log
conversationId: conversationId.current,
input: {
inputs: newResponseItem.inputs,
query: newResponseItem.query,
},
})
}
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestQuestions(data)
}
catch (e) {
setSuggestQuestions([])
}
}
},
onFile(file) {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought)
responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, file]
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onThought(thought) {
isAgentMode = true
const response = responseItem as any
if (thought.message_id && !hasSetResponseId)
response.id = thought.message_id
if (response.agent_thoughts.length === 0) {
response.agent_thoughts.push(thought)
}
else {
const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
// thought changed but still the same thought, so update.
if (lastThought.id === thought.id) {
thought.thought = lastThought.thought
thought.message_files = lastThought.message_files
responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought
}
else {
responseItem.agent_thoughts!.push(thought)
}
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onMessageEnd: (messageEnd) => {
if (messageEnd.metadata?.annotation_reply) {
responseItem.id = messageEnd.id
responseItem.annotation = ({
id: messageEnd.metadata.annotation_reply.id,
authorName: messageEnd.metadata.annotation_reply.account.name,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
return
}
responseItem.citation = messageEnd.metadata?.retriever_resources || []
const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onMessageReplace: (messageReplace) => {
responseItem.content = messageReplace.answer
},
onError() {
handleResponding(false)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
taskIdRef.current = task_id
responseItem.workflow_run_id = workflow_run_id
responseItem.workflowProcess = {
status: WorkflowRunningStatus.Running,
tracing: [],
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onWorkflowFinished: ({ data: workflowFinishedData }) => {
responseItem.workflowProcess!.status = workflowFinishedData.status as WorkflowRunningStatus
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onIterationStart: ({ data: iterationStartedData }) => {
responseItem.workflowProcess!.tracing!.push({
...iterationStartedData,
status: WorkflowRunningStatus.Running,
} as any)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onIterationFinish: ({ data: iterationFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
tracing[iterationIndex] = {
...tracing[iterationIndex],
...iterationFinishedData,
status: WorkflowRunningStatus.Succeeded,
} as any
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onNodeStarted: ({ data: nodeStartedData }) => {
if (nodeStartedData.iteration_id)
return
responseItem.workflowProcess!.tracing!.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
} as any)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onNodeFinished: ({ data: nodeFinishedData }) => {
if (nodeFinishedData.iteration_id)
return
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.node_id === nodeFinishedData.node_id
return item.node_id === nodeFinishedData.node_id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata.parallel_id)
})
responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
player.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
},
onTTSEnd: (messageId: string, audio: string) => {
player.playAudioWithAudio(audio, false)
},
})
return true
}, [
t,
chatTree.length,
threadMessages,
config?.suggested_questions_after_answer,
updateCurrentQAOnTree,
updateChatTreeNode,
notify,
handleResponding,
formatTime,
params.token,
params.appId,
pathname,
formSettings,
])
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
const targetQuestionId = chatList[index - 1].id
const targetAnswerId = chatList[index].id
updateChatTreeNode(targetQuestionId, {
content: query,
})
updateChatTreeNode(targetAnswerId, {
content: answer,
annotation: {
...chatList[index].annotation,
logAnnotation: undefined,
} as any,
})
}, [chatList, updateChatTreeNode])
const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => {
const targetQuestionId = chatList[index - 1].id
const targetAnswerId = chatList[index].id
updateChatTreeNode(targetQuestionId, {
content: query,
})
updateChatTreeNode(targetAnswerId, {
content: chatList[index].content,
annotation: {
id: annotationId,
authorName,
logAnnotation: {
content: answer,
account: {
id: '',
name: authorName,
email: '',
},
},
} as Annotation,
})
}, [chatList, updateChatTreeNode])
const handleAnnotationRemoved = useCallback((index: number) => {
const targetAnswerId = chatList[index].id
updateChatTreeNode(targetAnswerId, {
content: chatList[index].content,
annotation: {
...(chatList[index].annotation || {}),
id: '',
} as Annotation,
})
}, [chatList, updateChatTreeNode])
return {
chatList,
setTargetMessageId,
conversationId: conversationId.current,
isResponding,
setIsResponding,
handleSend,
suggestedQuestions,
handleRestart,
handleStop,
handleAnnotationEdited,
handleAnnotationAdded,
handleAnnotationRemoved,
}
}

View File

@@ -0,0 +1,326 @@
import type {
FC,
ReactNode,
} from 'react'
import {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { debounce } from 'lodash-es'
import { useShallow } from 'zustand/react/shallow'
import type {
ChatConfig,
ChatItem,
Feedback,
OnRegenerate,
OnSend,
} from '../types'
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
import Question from './question'
import Answer from './answer'
import ChatInputArea from './chat-input-area'
import TryToAsk from './try-to-ask'
import { ChatContextProvider } from './context'
import type { InputForm } from './type'
import cn from '@/utils/classnames'
import type { Emoji } from '@/app/components/tools/types'
import Button from '@/app/components/base/button'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { AppData } from '@/models/share'
export type ChatProps = {
appData?: AppData
chatList: ChatItem[]
config?: ChatConfig
isResponding?: boolean
noStopResponding?: boolean
onStopResponding?: () => void
noChatInput?: boolean
onSend?: OnSend
inputs?: Record<string, any>
inputsForm?: InputForm[]
onRegenerate?: OnRegenerate
chatContainerClassName?: string
chatContainerInnerClassName?: string
chatFooterClassName?: string
chatFooterInnerClassName?: string
suggestedQuestions?: string[]
showPromptLog?: boolean
questionIcon?: ReactNode
answerIcon?: ReactNode
allToolIcons?: Record<string, string | Emoji>
onAnnotationEdited?: (question: string, answer: string, index: number) => void
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void
chatNode?: ReactNode
onFeedback?: (messageId: string, feedback: Feedback) => void
chatAnswerContainerInner?: string
hideProcessDetail?: boolean
hideLogModal?: boolean
themeBuilder?: ThemeBuilder
switchSibling?: (siblingMessageId: string) => void
showFeatureBar?: boolean
showFileUpload?: boolean
onFeatureBarClick?: (state: boolean) => void
noSpacing?: boolean
}
const Chat: FC<ChatProps> = ({
appData,
config,
onSend,
inputs,
inputsForm,
onRegenerate,
chatList,
isResponding,
noStopResponding,
onStopResponding,
noChatInput,
chatContainerClassName,
chatContainerInnerClassName,
chatFooterClassName,
chatFooterInnerClassName,
suggestedQuestions,
showPromptLog,
questionIcon,
answerIcon,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
chatNode,
onFeedback,
chatAnswerContainerInner,
hideProcessDetail,
hideLogModal,
themeBuilder,
switchSibling,
showFeatureBar,
showFileUpload,
onFeatureBarClick,
noSpacing,
}) => {
const { t } = useTranslation()
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
})))
const [width, setWidth] = useState(0)
const chatContainerRef = useRef<HTMLDivElement>(null)
const chatContainerInnerRef = useRef<HTMLDivElement>(null)
const chatFooterRef = useRef<HTMLDivElement>(null)
const chatFooterInnerRef = useRef<HTMLDivElement>(null)
const userScrolledRef = useRef(false)
const handleScrollToBottom = useCallback(() => {
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current)
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
}, [chatList.length])
const handleWindowResize = useCallback(() => {
if (chatContainerRef.current)
setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8)
if (chatContainerRef.current && chatFooterRef.current)
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
}, [])
useEffect(() => {
handleScrollToBottom()
handleWindowResize()
}, [handleScrollToBottom, handleWindowResize])
useEffect(() => {
if (chatContainerRef.current) {
requestAnimationFrame(() => {
handleScrollToBottom()
handleWindowResize()
})
}
})
useEffect(() => {
window.addEventListener('resize', debounce(handleWindowResize))
return () => window.removeEventListener('resize', handleWindowResize)
}, [handleWindowResize])
useEffect(() => {
if (chatFooterRef.current && chatContainerRef.current) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { blockSize } = entry.borderBoxSize[0]
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
handleScrollToBottom()
}
})
resizeObserver.observe(chatFooterRef.current)
return () => {
resizeObserver.disconnect()
}
}
}, [handleScrollToBottom])
useEffect(() => {
const chatContainer = chatContainerRef.current
if (chatContainer) {
const setUserScrolled = () => {
if (chatContainer)
userScrolledRef.current = chatContainer.scrollHeight - chatContainer.scrollTop >= chatContainer.clientHeight + 300
}
chatContainer.addEventListener('scroll', setUserScrolled)
return () => chatContainer.removeEventListener('scroll', setUserScrolled)
}
}, [])
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
return (
<ChatContextProvider
config={config}
chatList={chatList}
isResponding={isResponding}
showPromptLog={showPromptLog}
questionIcon={questionIcon}
answerIcon={answerIcon}
onSend={onSend}
onRegenerate={onRegenerate}
onAnnotationAdded={onAnnotationAdded}
onAnnotationEdited={onAnnotationEdited}
onAnnotationRemoved={onAnnotationRemoved}
onFeedback={onFeedback}
>
<div className='relative h-full'>
<div
ref={chatContainerRef}
className={cn('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)}
>
{chatNode}
<div
ref={chatContainerInnerRef}
className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)}
>
{
chatList.map((item, index) => {
if (item.isAnswer) {
const isLast = item.id === chatList[chatList.length - 1]?.id
return (
<Answer
appData={appData}
key={item.id}
item={item}
question={chatList[index - 1]?.content}
index={index}
config={config}
answerIcon={answerIcon}
responding={isLast && isResponding}
showPromptLog={showPromptLog}
chatAnswerContainerInner={chatAnswerContainerInner}
hideProcessDetail={hideProcessDetail}
noChatInput={noChatInput}
switchSibling={switchSibling}
/>
)
}
return (
<Question
key={item.id}
item={item}
questionIcon={questionIcon}
theme={themeBuilder?.theme}
/>
)
})
}
</div>
</div>
<div
className={`absolute bottom-0 ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
ref={chatFooterRef}
style={{
background: 'linear-gradient(0deg, #F9FAFB 40%, rgba(255, 255, 255, 0.00) 100%)',
}}
>
<div
ref={chatFooterInnerRef}
className={cn('relative', chatFooterInnerClassName)}
>
{
!noStopResponding && isResponding && (
<div className='flex justify-center mb-2'>
<Button onClick={onStopResponding}>
<StopCircle className='mr-[5px] w-3.5 h-3.5 text-gray-500' />
<span className='text-xs text-gray-500 font-normal'>{t('appDebug.operation.stopResponding')}</span>
</Button>
</div>
)
}
{
hasTryToAsk && (
<TryToAsk
suggestedQuestions={suggestedQuestions}
onSend={onSend}
/>
)
}
{
!noChatInput && (
<ChatInputArea
showFeatureBar={showFeatureBar}
showFileUpload={showFileUpload}
featureBarDisabled={isResponding}
onFeatureBarClick={onFeatureBarClick}
visionConfig={config?.file_upload}
speechToTextConfig={config?.speech_to_text}
onSend={onSend}
inputs={inputs}
inputsForm={inputsForm}
theme={themeBuilder?.theme}
isResponding={isResponding}
/>
)
}
</div>
</div>
{showPromptLogModal && !hideLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{showAgentLogModal && !hideLogModal && (
<AgentLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
</div>
</ChatContextProvider>
)
}
export default memo(Chat)

View File

@@ -0,0 +1,17 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import s from './style.module.css'
export type ILoadingAnimProps = {
type: 'text' | 'avatar'
}
const LoadingAnim: FC<ILoadingAnimProps> = ({
type,
}) => {
return (
<div className={`${s['dot-flashing']} ${s[type]}`}></div>
)
}
export default React.memo(LoadingAnim)

View File

@@ -0,0 +1,82 @@
.dot-flashing {
position: relative;
animation: 1s infinite linear alternate;
animation-delay: 0.5s;
}
.dot-flashing::before,
.dot-flashing::after {
content: "";
display: inline-block;
position: absolute;
top: 0;
animation: 1s infinite linear alternate;
}
.dot-flashing::before {
animation-delay: 0s;
}
.dot-flashing::after {
animation-delay: 1s;
}
@keyframes dot-flashing {
0% {
background-color: #667085;
}
50%,
100% {
background-color: rgba(102, 112, 133, 0.3);
}
}
@keyframes dot-flashing-avatar {
0% {
background-color: #155EEF;
}
50%,
100% {
background-color: rgba(21, 94, 239, 0.3);
}
}
.text,
.text::before,
.text::after {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: #667085;
color: #667085;
animation-name: dot-flashing;
}
.text::before {
left: -7px;
}
.text::after {
left: 7px;
}
.avatar,
.avatar::before,
.avatar::after {
width: 2px;
height: 2px;
border-radius: 50%;
background-color: #155EEF;
color: #155EEF;
animation-name: dot-flashing-avatar;
}
.avatar::before {
left: -5px;
}
.avatar::after {
left: 5px;
}

View File

@@ -0,0 +1,42 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { File02 } from '@/app/components/base/icons/src/vender/line/files'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { useStore as useAppStore } from '@/app/components/app/store'
type LogProps = {
logItem: IChatItem
}
const Log: FC<LogProps> = ({
logItem,
}) => {
const { t } = useTranslation()
const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem)
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
const setShowAgentLogModal = useAppStore(s => s.setShowAgentLogModal)
const setShowMessageLogModal = useAppStore(s => s.setShowMessageLogModal)
const { workflow_run_id: runID, agent_thoughts } = logItem
const isAgent = agent_thoughts && agent_thoughts.length > 0
return (
<div
className='shrink-0 p-1 flex items-center justify-center rounded-[6px] font-medium text-gray-500 hover:bg-gray-50 cursor-pointer hover:text-gray-700'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setCurrentLogItem(logItem)
if (runID)
setShowMessageLogModal(true)
else if (isAgent)
setShowAgentLogModal(true)
else
setShowPromptLogModal(true)
}}
>
<File02 className='mr-1 w-4 h-4' />
<div className='text-xs leading-4'>{runID ? t('appLog.viewLog') : isAgent ? t('appLog.agentLog') : t('appLog.promptLog')}</div>
</div>
)
}
export default Log

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/react'
import type { ChatItem } from '../types'
import Question from './question'
import { User } from '@/app/components/base/icons/src/public/avatar'
const meta = {
title: 'Base/Chat Question',
component: Question,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {},
args: {},
} satisfies Meta<typeof Question>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
item: {
id: '1',
isAnswer: false,
content: 'You are a helpful assistant.',
} satisfies ChatItem,
theme: undefined,
questionIcon: <div className='w-full h-full rounded-full border-[0.5px] border-black/5'>
<User className='w-full h-full' />
</div>,
},
}

View File

@@ -0,0 +1,63 @@
import type {
FC,
ReactNode,
} from 'react'
import {
memo,
} from 'react'
import type { ChatItem } from '../types'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import { CssTransform } from '../embedded-chatbot/theme/utils'
import { User } from '@/app/components/base/icons/src/public/avatar'
import { Markdown } from '@/app/components/base/markdown'
import { FileList } from '@/app/components/base/file-uploader'
type QuestionProps = {
item: ChatItem
questionIcon?: ReactNode
theme: Theme | null | undefined
}
const Question: FC<QuestionProps> = ({
item,
questionIcon,
theme,
}) => {
const {
content,
message_files,
} = item
return (
<div className='flex justify-end mb-2 last:mb-0 pl-14'>
<div className='group relative mr-4 max-w-full'>
<div
className='px-4 py-3 bg-[#D1E9FF]/50 rounded-2xl text-sm text-gray-900'
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
>
{
!!message_files?.length && (
<FileList
files={message_files}
showDeleteAction={false}
showDownloadAction={true}
/>
)
}
<Markdown content={content} />
</div>
<div className='mt-1 h-[18px]' />
</div>
<div className='shrink-0 w-10 h-10'>
{
questionIcon || (
<div className='w-full h-full rounded-full border-[0.5px] border-black/5'>
<User className='w-full h-full' />
</div>
)
}
</div>
</div>
)
}
export default memo(Question)

View File

@@ -0,0 +1,58 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { ThoughtItem, ToolInfoInThought } from '../type'
import ToolDetail from '@/app/components/base/chat/chat/answer/tool-detail'
export type IThoughtProps = {
thought: ThoughtItem
isFinished: boolean
}
function getValue(value: string, isValueArray: boolean, index: number) {
if (isValueArray) {
try {
return JSON.parse(value)[index]
}
catch (e) {
}
}
return value
}
const Thought: FC<IThoughtProps> = ({
thought,
isFinished,
}) => {
const [toolNames, isValueArray]: [string[], boolean] = (() => {
try {
if (Array.isArray(JSON.parse(thought.tool)))
return [JSON.parse(thought.tool), true]
}
catch (e) {
}
return [[thought.tool], false]
})()
const toolThoughtList = toolNames.map((toolName, index) => {
return {
name: toolName,
label: thought.tool_labels?.toolName?.language ?? toolName,
input: getValue(thought.tool_input, isValueArray, index),
output: getValue(thought.observation, isValueArray, index),
isFinished,
}
})
return (
<div className='my-2 space-y-2'>
{toolThoughtList.map((item: ToolInfoInThought, index) => (
<ToolDetail
key={index}
payload={item}
/>
))}
</div>
)
}
export default React.memo(Thought)

View File

@@ -0,0 +1,28 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
type Props = {
isRequest: boolean
toolName: string
content: string
}
const Panel: FC<Props> = ({
isRequest,
toolName,
content,
}) => {
const { t } = useTranslation()
return (
<div className='rounded-md bg-gray-100 overflow-hidden border border-black/5'>
<div className='flex items-center px-2 py-1 leading-[18px] bg-gray-50 uppercase text-xs font-medium text-gray-500'>
{t(`tools.thought.${isRequest ? 'requestTitle' : 'responseTitle'}`)} {toolName}
</div>
<div className='p-2 border-t border-black/5 leading-4 text-xs text-gray-700'>{content}</div>
</div>
)
}
export default React.memo(Panel)

View File

@@ -0,0 +1,106 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiLoader2Line,
} from '@remixicon/react'
import type { ToolInfoInThought } from '../type'
import Panel from './panel'
import cn from '@/utils/classnames'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { DataSet as DataSetIcon } from '@/app/components/base/icons/src/public/thought'
import type { Emoji } from '@/app/components/tools/types'
import AppIcon from '@/app/components/base/app-icon'
type Props = {
payload: ToolInfoInThought
allToolIcons?: Record<string, string | Emoji>
}
const getIcon = (toolName: string, allToolIcons: Record<string, string | Emoji>) => {
if (toolName.startsWith('dataset_'))
return <DataSetIcon className='shrink-0'></DataSetIcon>
const icon = allToolIcons[toolName]
if (!icon)
return null
return (
typeof icon === 'string'
? (
<div
className='w-3 h-3 bg-cover bg-center rounded-[3px] shrink-0'
style={{
backgroundImage: `url(${icon})`,
}}
></div>
)
: (
<AppIcon
className='rounded-[3px] shrink-0'
size='xs'
icon={icon?.content}
background={icon?.background}
/>
))
}
const Tool: FC<Props> = ({
payload,
allToolIcons = {},
}) => {
const { t } = useTranslation()
const { name, label, input, isFinished, output } = payload
const toolName = name.startsWith('dataset_') ? t('dataset.knowledge') : name
const toolLabel = name.startsWith('dataset_') ? t('dataset.knowledge') : label
const [isShowDetail, setIsShowDetail] = useState(false)
const icon = getIcon(name, allToolIcons) as any
return (
<div>
<div className={cn(!isShowDetail && 'shadow-sm', !isShowDetail && 'inline-block', 'max-w-full overflow-x-auto bg-white rounded-md')}>
<div
className={cn('flex items-center h-7 px-2 cursor-pointer')}
onClick={() => setIsShowDetail(!isShowDetail)}
>
{!isFinished && (
<RiLoader2Line className='w-3 h-3 text-gray-500 animate-spin shrink-0' />
)}
{isFinished && !isShowDetail && (
<CheckCircle className='w-3 h-3 text-[#12B76A] shrink-0' />
)}
{isFinished && isShowDetail && (
icon
)}
<span className='mx-1 text-xs font-medium text-gray-500 shrink-0'>
{t(`tools.thought.${isFinished ? 'used' : 'using'}`)}
</span>
<span
className='text-xs font-medium text-gray-700 truncate'
title={toolLabel}
>
{toolLabel}
</span>
<RiArrowDownSLine
className={cn(isShowDetail && 'rotate-180', 'ml-1 w-3 h-3 text-gray-500 select-none cursor-pointer shrink-0')}
/>
</div>
{isShowDetail && (
<div className='border-t border-black/5 p-2 space-y-2 '>
<Panel
isRequest={true}
toolName={toolName}
content={input} />
{output && (
<Panel
isRequest={false}
toolName={toolName}
content={output as string} />
)}
</div>
)}
</div>
</div>
)
}
export default React.memo(Tool)

View File

@@ -0,0 +1,56 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { OnSend } from '../types'
import { Star04 } from '@/app/components/base/icons/src/vender/solid/shapes'
import Button from '@/app/components/base/button'
type TryToAskProps = {
suggestedQuestions: string[]
onSend: OnSend
}
const TryToAsk: FC<TryToAskProps> = ({
suggestedQuestions,
onSend,
}) => {
const { t } = useTranslation()
return (
<div>
<div className='flex items-center mb-2.5 py-2'>
<div
className='grow h-[1px]'
style={{
background: 'linear-gradient(270deg, #F3F4F6 0%, rgba(243, 244, 246, 0) 100%)',
}}
/>
<div className='shrink-0 flex items-center px-3 text-gray-500'>
<Star04 className='mr-1 w-2.5 h-2.5' />
<span className='text-xs text-gray-500 font-medium'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.tryToAsk')}</span>
</div>
<div
className='grow h-[1px]'
style={{
background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)',
}}
/>
</div>
<div className='flex flex-wrap justify-center'>
{
suggestedQuestions.map((suggestQuestion, index) => (
<Button
key={index}
variant='secondary-accent'
className='mb-2 mr-2 last:mr-0'
onClick={() => onSend(suggestQuestion)}
>
{suggestQuestion}
</Button>
))
}
</div>
</div>
)
}
export default memo(TryToAsk)

View File

@@ -0,0 +1,146 @@
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Annotation, MessageRating } from '@/models/log'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVarType } from '@/app/components/workflow/types'
import type { FileResponse } from '@/types/workflow'
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 ToolInfoInThought = {
name: string
label: string
input: string
output: string
isFinished: boolean
}
export type ThoughtItem = {
id: string
tool: string // plugin or dataset. May has multi.
thought: string
tool_input: string
tool_labels?: { [key: string]: TypeWithI18N }
message_id: string
observation: string
position: number
files?: string[]
message_files?: FileEntity[]
}
export type CitationItem = {
content: string
data_source_type: string
dataset_name: string
dataset_id: string
document_id: string
document_name: string
hit_count: number
index_node_hash: string
segment_id: string
segment_position: number
score: number
word_count: number
}
export type IChatItem = {
id: string
content: string
citation?: CitationItem[]
/**
* 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
suggestedQuestions?: string[]
log?: { role: string; text: string; files?: FileEntity[] }[]
agent_thoughts?: ThoughtItem[]
message_files?: FileEntity[]
workflow_run_id?: string
// for agent log
conversationId?: string
input?: any
parentMessageId?: string | null
siblingCount?: number
siblingIndex?: number
prevSibling?: string
nextSibling?: string
}
export type Metadata = {
retriever_resources?: CitationItem[]
annotation_reply: {
id: string
account: {
id: string
name: string
}
}
}
export type MessageEnd = {
id: string
metadata: Metadata
files?: FileResponse[]
}
export type MessageReplace = {
id: string
task_id: string
answer: string
conversation_id: string
}
export type AnnotationReply = {
id: string
task_id: string
answer: string
conversation_id: string
annotation_id: string
annotation_author_name: string
}
export type InputForm = {
type: InputVarType
label: string
variable: any
required: boolean
[key: string]: any
}

View File

@@ -0,0 +1,32 @@
import type { InputForm } from './type'
import { InputVarType } from '@/app/components/workflow/types'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
export const processOpeningStatement = (openingStatement: string, inputs: Record<string, any>, inputsForm: InputForm[]) => {
if (!openingStatement)
return openingStatement
return openingStatement.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
const name = inputs[key]
if (name) { // has set value
return name
}
const valueObj = inputsForm.find(v => v.variable === key)
return valueObj ? `{{${valueObj.label}}}` : match
})
}
export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: InputForm[]) => {
const processedInputs = { ...inputs }
inputsForm.forEach((item) => {
if (item.type === InputVarType.multiFiles && inputs[item.variable])
processedInputs[item.variable] = getProcessedFiles(inputs[item.variable])
if (item.type === InputVarType.singleFile && inputs[item.variable])
processedInputs[item.variable] = getProcessedFiles([inputs[item.variable]])[0]
})
return processedInputs
}

View File

@@ -0,0 +1,2 @@
export const CONVERSATION_ID_INFO = 'conversationIdInfo'
export const UUID_NIL = '00000000-0000-0000-0000-000000000000'

View File

@@ -0,0 +1,169 @@
import { useCallback, useEffect, useMemo } 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 { useEmbeddedChatbotContext } from './context'
import ConfigPanel from './config-panel'
import { isDify } from './utils'
import cn from '@/utils/classnames'
import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
} from '@/service/share'
import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar'
import AnswerIcon from '@/app/components/base/answer-icon'
const ChatWrapper = () => {
const {
appData,
appParams,
appPrevChatList,
currentConversationId,
currentConversationItem,
inputsForms,
newConversationInputs,
handleNewConversationCompleted,
isMobile,
isInstalledApp,
appId,
appMeta,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
} = useEmbeddedChatbotContext()
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: currentConversationId ? currentConversationItem?.introduction : (config as any).opening_statement,
} as ChatConfig
}, [appParams, currentConversationItem?.introduction, currentConversationId])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
isResponding,
suggestedQuestions,
} = useChat(
appConfig,
{
inputs: (currentConversationId ? currentConversationItem?.inputs : newConversationInputs) as any,
inputsForm: inputsForms,
},
appPrevChatList,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
)
useEffect(() => {
if (currentChatInstanceRef.current)
currentChatInstanceRef.current.handleStop = handleStop
}, [currentChatInstanceRef, handleStop])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
files,
inputs: currentConversationId ? currentConversationItem?.inputs : 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,
currentConversationItem,
newConversationInputs,
isInstalledApp,
appId,
])
const doRegenerate = useCallback((chatItem: ChatItemInTree) => {
const question = chatList.find(item => item.id === chatItem.parentMessageId)!
const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
const chatNode = useMemo(() => {
if (inputsForms.length) {
return (
<>
{!currentConversationId && (
<div className={cn('mx-auto w-full max-w-full tablet:px-4', isMobile && 'px-4')}>
<div className='mb-6' />
<ConfigPanel />
<div
className='my-6 h-[1px]'
style={{ background: 'linear-gradient(90deg, rgba(242, 244, 247, 0.00) 0%, #F2F4F7 49.17%, rgba(242, 244, 247, 0.00) 100%)' }}
/>
</div>
)}
</>
)
}
return null
}, [currentConversationId, inputsForms, isMobile])
const answerIcon = isDify()
? <LogoAvatar className='relative shrink-0' />
: (appData?.site && appData.site.use_icon_as_answer_icon)
? <AnswerIcon
iconType={appData.site.icon_type}
icon={appData.site.icon}
background={appData.site.icon_background}
imageUrl={appData.site.icon_url}
/>
: null
return (
<Chat
appData={appData}
config={appConfig}
chatList={chatList}
isResponding={isResponding}
chatContainerInnerClassName={cn('mx-auto w-full max-w-full tablet:px-4', isMobile && 'px-4')}
chatFooterClassName='pb-4'
chatFooterInnerClassName={cn('mx-auto w-full max-w-full tablet:px-4', isMobile && 'px-4')}
onSend={doSend}
inputs={currentConversationId ? currentConversationItem?.inputs as any : newConversationInputs}
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
chatNode={chatNode}
allToolIcons={appMeta?.tool_icons || {}}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
/>
)
}
export default ChatWrapper

View File

@@ -0,0 +1,47 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import Textarea from '@/app/components/base/textarea'
type InputProps = {
form: any
value: string
onChange: (variable: string, value: string) => void
}
const FormInput: FC<InputProps> = ({
form,
value,
onChange,
}) => {
const { t } = useTranslation()
const {
type,
label,
required,
max_length,
variable,
} = form
if (type === 'paragraph') {
return (
<Textarea
value={value}
className='resize-none'
onChange={e => onChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
return (
<input
className='grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none'
value={value || ''}
maxLength={max_length}
onChange={e => onChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
export default memo(FormInput)

View File

@@ -0,0 +1,129 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useEmbeddedChatbotContext } from '../context'
import Input from './form-input'
import { PortalSelect } from '@/app/components/base/select'
import { InputVarType } from '@/app/components/workflow/types'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
const Form = () => {
const { t } = useTranslation()
const {
appParams,
inputsForms,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
isMobile,
} = useEmbeddedChatbotContext()
const handleFormChange = useCallback((variable: string, value: any) => {
handleNewConversationInputsChange({
...newConversationInputsRef.current,
[variable]: value,
})
}, [newConversationInputsRef, handleNewConversationInputsChange])
const renderField = (form: any) => {
const {
label,
required,
variable,
options,
} = form
if (form.type === 'text-input' || form.type === 'paragraph') {
return (
<Input
form={form}
value={newConversationInputs[variable]}
onChange={handleFormChange}
/>
)
}
if (form.type === 'number') {
return (
<input
className="grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none"
type="number"
value={newConversationInputs[variable] || ''}
onChange={e => handleFormChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
if (form.type === 'number') {
return (
<input
className="grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none"
type="number"
value={newConversationInputs[variable] || ''}
onChange={e => handleFormChange(variable, e.target.value)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
if (form.type === InputVarType.singleFile) {
return (
<FileUploaderInAttachmentWrapper
value={newConversationInputs[variable] ? [newConversationInputs[variable]] : []}
onChange={files => handleFormChange(variable, files[0])}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
allowed_file_upload_methods: form.allowed_file_upload_methods,
number_limits: 1,
fileUploadConfig: (appParams as any).system_parameters,
}}
/>
)
}
if (form.type === InputVarType.multiFiles) {
return (
<FileUploaderInAttachmentWrapper
value={newConversationInputs[variable]}
onChange={files => handleFormChange(variable, files)}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
allowed_file_upload_methods: form.allowed_file_upload_methods,
number_limits: form.max_length,
fileUploadConfig: (appParams as any).system_parameters,
}}
/>
)
}
return (
<PortalSelect
popupClassName='w-[200px]'
value={newConversationInputs[variable]}
items={options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(variable, item.value as string)}
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
/>
)
}
if (!inputsForms.length)
return null
return (
<div className='mb-4 py-2'>
{
inputsForms.map(form => (
<div
key={form.variable}
className={`flex mb-3 last-of-type:mb-0 text-sm text-gray-900 ${isMobile && '!flex-wrap'}`}
>
<div className={`shrink-0 mr-2 py-2 w-[128px] ${isMobile && '!w-full'}`}>{form.label}</div>
{renderField(form)}
</div>
))
}
</div>
)
}
export default Form

View File

@@ -0,0 +1,180 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEmbeddedChatbotContext } from '../context'
import { useThemeContext } from '../theme/theme-context'
import { CssTransform } from '../theme/utils'
import Form from './form'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
import AppIcon from '@/app/components/base/app-icon'
import { MessageDotsCircle } from '@/app/components/base/icons/src/vender/solid/communication'
import { Edit02 } from '@/app/components/base/icons/src/vender/line/general'
import { Star06 } from '@/app/components/base/icons/src/vender/solid/shapes'
import LogoSite from '@/app/components/base/logo/logo-site'
const ConfigPanel = () => {
const { t } = useTranslation()
const {
appData,
inputsForms,
handleStartChat,
showConfigPanelBeforeChat,
isMobile,
} = useEmbeddedChatbotContext()
const [collapsed, setCollapsed] = useState(true)
const customConfig = appData?.custom_config
const site = appData?.site
const themeBuilder = useThemeContext()
return (
<div className='flex flex-col max-h-[80%] w-full max-w-[720px]'>
<div
className={cn(
'grow rounded-xl overflow-y-auto',
showConfigPanelBeforeChat && 'border-[0.5px] border-gray-100 shadow-lg',
!showConfigPanelBeforeChat && collapsed && 'border border-indigo-100',
!showConfigPanelBeforeChat && !collapsed && 'border-[0.5px] border-gray-100 shadow-lg',
)}
>
<div
style={CssTransform(themeBuilder.theme?.roundedBackgroundColorStyle ?? '')}
className={`
flex flex-wrap px-6 py-4 rounded-t-xl bg-indigo-25
${isMobile && '!px-4 !py-3'}
`}
>
{
showConfigPanelBeforeChat && (
<>
<div className='flex items-center h-8 text-2xl font-semibold text-gray-800'>
<AppIcon
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
imageUrl={appData?.site.icon_url}
background='transparent'
size='small'
className="mr-2"
/>
{appData?.site.title}
</div>
{
appData?.site.description && (
<div className='mt-2 w-full text-sm text-gray-500'>
{appData?.site.description}
</div>
)
}
</>
)
}
{
!showConfigPanelBeforeChat && collapsed && (
<>
<Star06 className='mr-1 mt-1 w-4 h-4 text-indigo-600' />
<div className='grow py-[3px] text-[13px] text-indigo-600 leading-[18px] font-medium'>
{t('share.chat.configStatusDes')}
</div>
<Button
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
variant='secondary-accent'
size='small'
className='shrink-0'
onClick={() => setCollapsed(false)}
>
<Edit02 className='mr-1 w-3 h-3' />
{t('common.operation.edit')}
</Button>
</>
)
}
{
!showConfigPanelBeforeChat && !collapsed && (
<>
<Star06 className='mr-1 mt-1 w-4 h-4 text-indigo-600' />
<div className='grow py-[3px] text-[13px] text-indigo-600 leading-[18px] font-medium'>
{t('share.chat.privatePromptConfigTitle')}
</div>
</>
)
}
</div>
{
!collapsed && !showConfigPanelBeforeChat && (
<div className='p-6 rounded-b-xl'>
<Form />
<div className={cn('pl-[136px] flex items-center', isMobile && '!pl-0')}>
<Button
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
variant='primary'
className='mr-2'
onClick={() => {
setCollapsed(true)
handleStartChat()
}}
>
{t('common.operation.save')}
</Button>
<Button
onClick={() => setCollapsed(true)}
>
{t('common.operation.cancel')}
</Button>
</div>
</div>
)
}
{
showConfigPanelBeforeChat && (
<div className='p-6 rounded-b-xl'>
<Form />
<Button
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
className={cn(inputsForms.length && !isMobile && 'ml-[136px]')}
variant='primary'
size='large'
onClick={handleStartChat}
>
<MessageDotsCircle className='mr-2 w-4 h-4 text-white' />
{t('share.chat.startChat')}
</Button>
</div>
)
}
</div>
{
showConfigPanelBeforeChat && (site || customConfig) && (
<div className='mt-4 flex flex-wrap justify-between items-center py-2 text-xs text-gray-400'>
{site?.privacy_policy
? <div className={cn(isMobile && 'mb-2 w-full text-center')}>{t('share.chat.privacyPolicyLeft')}
<a
className='text-gray-500 px-1'
href={site?.privacy_policy}
target='_blank' rel='noopener noreferrer'>{t('share.chat.privacyPolicyMiddle')}</a>
{t('share.chat.privacyPolicyRight')}
</div>
: <div>
</div>}
{
customConfig?.remove_webapp_brand
? null
: (
<div className={cn('flex items-center justify-end', isMobile && 'w-full')}>
<div className='flex items-center pr-3 space-x-3'>
<span className='uppercase'>{t('share.chat.poweredBy')}</span>
{
customConfig?.replace_webapp_logo
? <img src={customConfig?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
: <LogoSite className='!h-5' />
}
</div>
</div>
)
}
</div>
)
}
</div>
)
}
export default ConfigPanel

View File

@@ -0,0 +1,68 @@
'use client'
import type { RefObject } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type {
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import type { ThemeBuilder } from './theme/theme-context'
import type {
AppConversationData,
AppData,
AppMeta,
ConversationItem,
} from '@/models/share'
export type EmbeddedChatbotContextValue = {
appInfoError?: any
appInfoLoading?: boolean
appMeta?: AppMeta
appData?: AppData
appParams?: ChatConfig
appChatListDataLoading?: boolean
currentConversationId: string
currentConversationItem?: ConversationItem
appPrevChatList: ChatItem[]
pinnedConversationList: AppConversationData['data']
conversationList: AppConversationData['data']
showConfigPanelBeforeChat: boolean
newConversationInputs: Record<string, any>
newConversationInputsRef: RefObject<Record<string, any>>
handleNewConversationInputsChange: (v: Record<string, any>) => void
inputsForms: any[]
handleNewConversation: () => void
handleStartChat: () => void
handleChangeConversation: (conversationId: string) => 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
}
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
currentConversationId: '',
appPrevChatList: [],
pinnedConversationList: [],
conversationList: [],
showConfigPanelBeforeChat: false,
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: () => {},
inputsForms: [],
handleNewConversation: () => {},
handleStartChat: () => {},
handleChangeConversation: () => {},
handleNewConversationCompleted: () => {},
chatShouldReloadKey: '',
isMobile: false,
isInstalledApp: false,
handleFeedback: () => {},
currentChatInstanceRef: { current: { handleStop: () => {} } },
})
export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext)

View File

@@ -0,0 +1,56 @@
import type { FC } from 'react'
import React from 'react'
import { RiRefreshLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { Theme } from './theme/theme-context'
import { CssTransform } from './theme/utils'
import Tooltip from '@/app/components/base/tooltip'
export type IHeaderProps = {
isMobile?: boolean
customerIcon?: React.ReactNode
title: string
theme?: Theme
onCreateNewChat?: () => void
}
const Header: FC<IHeaderProps> = ({
isMobile,
customerIcon,
title,
theme,
onCreateNewChat,
}) => {
const { t } = useTranslation()
if (!isMobile)
return null
return (
<div
className={`
shrink-0 flex items-center justify-between h-14 px-4
`}
style={Object.assign({}, CssTransform(theme?.backgroundHeaderColorStyle ?? ''), CssTransform(theme?.headerBorderBottomStyle ?? '')) }
>
<div className="flex items-center space-x-2">
{customerIcon}
<div
className={'text-sm font-bold text-white'}
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
>
{title}
</div>
</div>
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<div className='flex cursor-pointer hover:rounded-lg hover:bg-black/5 w-8 h-8 items-center justify-center' onClick={() => {
onCreateNewChat?.()
}}>
<RiRefreshLine className="h-4 w-4 text-sm font-bold text-white" color={theme?.colorPathOnHeader}/>
</div>
</Tooltip>
</div>
)
}
export default React.memo(Header)

View File

@@ -0,0 +1,326 @@
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 {
ChatConfig,
Feedback,
} from '../types'
import { CONVERSATION_ID_INFO } from '../constants'
import { getPrevChatList, getProcessedInputsFromUrlParams } from '../utils'
import {
fetchAppInfo,
fetchAppMeta,
fetchAppParams,
fetchChatList,
fetchConversations,
generationConversationName,
updateFeedback,
} from '@/service/share'
import type {
// AppData,
ConversationItem,
} from '@/models/share'
import { useToastContext } from '@/app/components/base/toast'
import { changeLanguage } from '@/i18n/i18next-config'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
export const useEmbeddedChatbot = () => {
const isInstalledApp = false
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
const appData = useMemo(() => {
return appInfo
}, [appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
useEffect(() => {
if (appInfo?.site.default_language)
changeLanguage(appInfo.site.default_language)
}, [appInfo])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, string>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || ''] || '', [appId, conversationIdInfo])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
if (appId) {
setConversationIdInfo({
...conversationIdInfo,
[appId || '']: changeConversationId,
})
}
}, [appId, conversationIdInfo, setConversationIdInfo])
const [showConfigPanelBeforeChat, setShowConfigPanelBeforeChat] = useState(true)
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId))
const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId))
const { data: appPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
const appPrevChatList = useMemo(
() => (currentConversationId && appChatListData?.data.length)
? getPrevChatList(appChatListData.data)
: [],
[appChatListData, currentConversationId],
)
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
const { t } = useTranslation()
const newConversationInputsRef = useRef<Record<string, any>>({})
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
const [initInputs, setInitInputs] = useState<Record<string, any>>({})
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
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,
type: 'paragraph',
}
}
if (item.number) {
const convertedNumber = Number(initInputs[item.number.variable]) ?? undefined
return {
...item.number,
default: convertedNumber || item.default,
type: 'number',
}
}
if (item.select) {
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
return {
...item.select,
default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.default,
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
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,
type: 'text-input',
}
})
}, [initInputs, appParams])
useEffect(() => {
// init inputs from url params
setInitInputs(getProcessedInputsFromUrlParams())
}, [])
useEffect(() => {
const conversationInputs: Record<string, any> = {}
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<ConversationItem[]>([])
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 { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required }) => required)
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])
const handleStartChat = useCallback(() => {
if (checkInputsRequired()) {
setShowConfigPanelBeforeChat(false)
setShowNewConversationItemInList(true)
}
}, [setShowConfigPanelBeforeChat, setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: () => { } })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
handleConversationIdInfoChange(conversationId)
if (conversationId === '' && !checkInputsRequired(true))
setShowConfigPanelBeforeChat(true)
else
setShowConfigPanelBeforeChat(false)
}, [handleConversationIdInfoChange, setShowConfigPanelBeforeChat, checkInputsRequired])
const handleNewConversation = useCallback(() => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
if (showNewConversationItemInList) {
handleChangeConversation('')
}
else if (currentConversationId) {
handleConversationIdInfoChange('')
setShowConfigPanelBeforeChat(true)
setShowNewConversationItemInList(true)
handleNewConversationInputsChange({})
}
}, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange])
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 } }, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify])
return {
appInfoError,
appInfoLoading,
isInstalledApp,
appId,
currentConversationId,
currentConversationItem,
handleConversationIdInfoChange,
appData,
appParams: appParams || {} as ChatConfig,
appMeta,
appPinnedConversationData,
appConversationData,
appConversationDataLoading,
appChatListData,
appChatListDataLoading,
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
setShowConfigPanelBeforeChat,
setShowNewConversationItemInList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handleNewConversationCompleted,
newConversationId,
chatShouldReloadKey,
handleFeedback,
currentChatInstanceRef,
}
}

View File

@@ -0,0 +1,206 @@
import {
useEffect,
useState,
} from 'react'
import { useAsyncEffect } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { RiLoopLeftLine } from '@remixicon/react'
import {
EmbeddedChatbotContext,
useEmbeddedChatbotContext,
} from './context'
import { useEmbeddedChatbot } from './hooks'
import { isDify } from './utils'
import { useThemeContext } from './theme/theme-context'
import cn from '@/utils/classnames'
import { checkOrSetAccessToken } from '@/app/components/share/utils'
import AppUnavailable from '@/app/components/base/app-unavailable'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Loading from '@/app/components/base/loading'
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
import Header from '@/app/components/base/chat/embedded-chatbot/header'
import ConfigPanel from '@/app/components/base/chat/embedded-chatbot/config-panel'
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
import Tooltip from '@/app/components/base/tooltip'
const Chatbot = () => {
const { t } = useTranslation()
const {
isMobile,
appInfoError,
appInfoLoading,
appData,
appPrevChatList,
showConfigPanelBeforeChat,
appChatListDataLoading,
handleNewConversation,
themeBuilder,
} = useEmbeddedChatbotContext()
const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatList.length)
const customConfig = appData?.custom_config
const site = appData?.site
const difyIcon = <LogoHeader />
useEffect(() => {
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
if (site) {
if (customConfig)
document.title = `${site.title}`
else
document.title = `${site.title} - Powered by Dify`
}
}, [site, customConfig, themeBuilder])
if (appInfoLoading) {
return (
<Loading type='app' />
)
}
if (appInfoError) {
return (
<AppUnavailable />
)
}
return (
<div>
<Header
isMobile={isMobile}
title={site?.title || ''}
customerIcon={isDify() ? difyIcon : ''}
theme={themeBuilder?.theme}
onCreateNewChat={handleNewConversation}
/>
<div className='flex bg-white overflow-hidden'>
<div className={cn('h-[100vh] grow flex flex-col overflow-y-auto', isMobile && '!h-[calc(100vh_-_3rem)]')}>
{showConfigPanelBeforeChat && !appChatListDataLoading && !appPrevChatList.length && (
<div className={cn('flex w-full items-center justify-center h-full tablet:px-4', isMobile && 'px-4')}>
<ConfigPanel />
</div>
)}
{appChatListDataLoading && chatReady && (
<Loading type='app' />
)}
{chatReady && !appChatListDataLoading && (
<div className='relative h-full pt-8 mx-auto w-full max-w-[720px]'>
{!isMobile && (
<div className='absolute top-2.5 right-3 z-20'>
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<div className='p-1.5 bg-white border-[0.5px] border-gray-100 rounded-lg shadow-md cursor-pointer' onClick={handleNewConversation}>
<RiLoopLeftLine className="h-4 w-4 text-gray-500"/>
</div>
</Tooltip>
</div>
)}
<ChatWrapper />
</div>
)}
</div>
</div>
</div>
)
}
const EmbeddedChatbotWrapper = () => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const themeBuilder = useThemeContext()
const {
appInfoError,
appInfoLoading,
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
} = useEmbeddedChatbot()
return <EmbeddedChatbotContext.Provider value={{
appInfoError,
appInfoLoading,
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatList,
pinnedConversationList,
conversationList,
showConfigPanelBeforeChat,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isMobile,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
}}>
<Chatbot />
</EmbeddedChatbotContext.Provider>
}
const EmbeddedChatbot = () => {
const [initialized, setInitialized] = useState(false)
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false)
useAsyncEffect(async () => {
if (!initialized) {
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 <AppUnavailable isUnknownReason={isUnknownReason} />
return <EmbeddedChatbotWrapper />
}
export default EmbeddedChatbot

View File

@@ -0,0 +1,72 @@
import { createContext, useContext } from 'use-context-selector'
import { hexToRGBA } from './utils'
export class Theme {
public chatColorTheme: string | null
public chatColorThemeInverted: boolean
public primaryColor = '#1C64F2'
public backgroundHeaderColorStyle = 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)'
public headerBorderBottomStyle = ''
public colorFontOnHeaderStyle = 'color: white'
public colorPathOnHeader = 'white'
public backgroundButtonDefaultColorStyle = 'backgroundColor: #1C64F2'
public roundedBackgroundColorStyle = 'backgroundColor: rgb(245 248 255)'
public chatBubbleColorStyle = 'backgroundColor: rgb(225 239 254)'
public chatBubbleColor = 'rgb(225 239 254)'
constructor(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
this.chatColorTheme = chatColorTheme
this.chatColorThemeInverted = chatColorThemeInverted
this.configCustomColor()
this.configInvertedColor()
}
private configCustomColor() {
if (this.chatColorTheme !== null && this.chatColorTheme !== '') {
this.primaryColor = this.chatColorTheme ?? '#1C64F2'
this.backgroundHeaderColorStyle = `backgroundColor: ${this.primaryColor}`
this.backgroundButtonDefaultColorStyle = `backgroundColor: ${this.primaryColor}; color: ${this.colorFontOnHeaderStyle};`
this.roundedBackgroundColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.05)}`
this.chatBubbleColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.15)}`
this.chatBubbleColor = `${hexToRGBA(this.primaryColor, 0.15)}`
}
}
private configInvertedColor() {
if (this.chatColorThemeInverted) {
this.backgroundHeaderColorStyle = 'backgroundColor: #ffffff'
this.colorFontOnHeaderStyle = `color: ${this.primaryColor}`
this.headerBorderBottomStyle = 'borderBottom: 1px solid #ccc'
this.colorPathOnHeader = this.primaryColor
}
}
}
export class ThemeBuilder {
private _theme?: Theme
private buildChecker = false
public get theme() {
if (this._theme === undefined)
throw new Error('The theme should be built first and then accessed')
else
return this._theme
}
public buildTheme(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
if (!this.buildChecker) {
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
this.buildChecker = true
}
else {
if (this.theme?.chatColorTheme !== chatColorTheme || this.theme?.chatColorThemeInverted !== chatColorThemeInverted) {
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
this.buildChecker = true
}
}
}
}
const ThemeContext = createContext<ThemeBuilder>(new ThemeBuilder())
export const useThemeContext = () => useContext(ThemeContext)

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