Initial commit

This commit is contained in:
饺子大魔王
2025-04-11 08:14:27 +08:00
commit 626af60456
1540 changed files with 110511 additions and 0 deletions
@@ -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/chat/thought'
import { FileList } from '@/app/components/base/file-uploader'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
interface 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.filter((item: any) => item.id).map((item: any) => ({ ...item, related_id: item.id })))}
showDeleteAction={false}
showDownloadAction={true}
canPreview={true}
/>
)
}
</div>
))}
</div>
)
}
export default memo(AgentContent)
@@ -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'
interface 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)
+235
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/chat/loading-anim'
import Citation from '@/app/components/chat/citation'
// TODO mars
// 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)
+46
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)
+239
View File
@@ -0,0 +1,239 @@
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'
// TODO MARS
// 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/chat/log'
interface 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)
@@ -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)
@@ -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
@@ -0,0 +1,108 @@
import {
useEffect,
useMemo,
useState,
} from 'react'
import {
RiArrowRightSLine,
RiErrorWarningFill,
RiLoader2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { ChatItem, WorkflowProcess } from '../../types'
// TODO MARS
// 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'
type WorkflowProcessProps = {
data: WorkflowProcess
item?: ChatItem
expand?: boolean
hideInfo?: boolean
hideProcessDetail?: boolean
readonly?: boolean
}
const WorkflowProcessItem = ({
data,
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])
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}
hideNodeInfo={hideInfo}
hideNodeProcessDetail={hideProcessDetail}
/>
}
</div>
)
} */}
</div>
)
}
export default WorkflowProcessItem
@@ -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,
}
}
@@ -0,0 +1,230 @@
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'
// TODO mars
// 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)) {
// debugger
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 bg-transparent 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
@@ -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)
@@ -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,
}
}
+125
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
+131
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
@@ -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
+46
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
+66
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
@@ -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)
@@ -0,0 +1,29 @@
export function hexToRGBA(hex: string, opacity: number): string {
hex = hex.replace('#', '')
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
// Returning an RGB color object
return `rgba(${r},${g},${b},${opacity.toString()})`
}
/**
* Since strings cannot be directly assigned to the 'style' attribute in JSX,
* this method transforms the string into an object representation of the styles.
*/
export function CssTransform(cssString: string): object {
if (cssString.length === 0)
return {}
const style: object = {}
const propertyValuePairs = cssString.split(';')
for (const pair of propertyValuePairs) {
if (pair.trim().length > 0) {
const [property, value] = pair.split(':')
Object.assign(style, { [property.trim()]: value.trim() })
}
}
return style
}
+665
View File
@@ -0,0 +1,665 @@
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
// debugger
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) => {
console.log('----taskid', taskId)
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,
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.03647 1.5547C0.59343 0.890144 1.06982 0 1.86852 0H8V12L1.03647 1.5547Z" fill="#F3F4F6"/>
</svg>

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 804 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.96353 1.5547C7.40657 0.890144 6.93018 0 6.13148 0H0V12L6.96353 1.5547Z" fill="#E1EFFE"/>
</svg>

After

Width:  |  Height:  |  Size: 202 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.447 16.8939C23.6128 16.8108 23.7523 16.6832 23.8498 16.5253C23.9473 16.3674 23.9989 16.1855 23.9989 15.9999C23.9989 15.8144 23.9473 15.6325 23.8498 15.4746C23.7523 15.3167 23.6128 15.1891 23.447 15.1059L9.44697 8.10595C9.27338 8.01909 9.07827 7.98463 8.88543 8.00677C8.69259 8.02891 8.51036 8.1067 8.36098 8.23064C8.2116 8.35458 8.10151 8.51931 8.04415 8.70475C7.9868 8.89019 7.98465 9.08831 8.03797 9.27495L9.46697 14.2749C9.52674 14.4839 9.65297 14.6677 9.82655 14.7985C10.0001 14.9294 10.2116 15.0001 10.429 14.9999H15C15.2652 14.9999 15.5195 15.1053 15.7071 15.2928C15.8946 15.4804 16 15.7347 16 15.9999C16 16.2652 15.8946 16.5195 15.7071 16.7071C15.5195 16.8946 15.2652 16.9999 15 16.9999H10.429C10.2116 16.9998 10.0001 17.0705 9.82655 17.2013C9.65297 17.3322 9.52674 17.516 9.46697 17.7249L8.03897 22.7249C7.98554 22.9115 7.98756 23.1096 8.04478 23.2951C8.10201 23.4805 8.21195 23.6453 8.36122 23.7693C8.51049 23.8934 8.69263 23.9713 8.88542 23.9936C9.07821 24.0159 9.27332 23.9816 9.44697 23.8949L23.447 16.8949V16.8939Z" fill="#1C64F2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.447 16.8939C23.6128 16.8108 23.7523 16.6832 23.8498 16.5253C23.9473 16.3674 23.9989 16.1855 23.9989 15.9999C23.9989 15.8144 23.9473 15.6325 23.8498 15.4746C23.7523 15.3167 23.6128 15.1891 23.447 15.1059L9.44697 8.10595C9.27338 8.01909 9.07827 7.98463 8.88543 8.00677C8.69259 8.02891 8.51036 8.1067 8.36098 8.23064C8.2116 8.35458 8.10151 8.51931 8.04415 8.70475C7.9868 8.89019 7.98465 9.08831 8.03797 9.27495L9.46697 14.2749C9.52674 14.4839 9.65297 14.6677 9.82655 14.7985C10.0001 14.9294 10.2116 15.0001 10.429 14.9999H15C15.2652 14.9999 15.5195 15.1053 15.7071 15.2928C15.8946 15.4804 16 15.7347 16 15.9999C16 16.2652 15.8946 16.5195 15.7071 16.7071C15.5195 16.8946 15.2652 16.9999 15 16.9999H10.429C10.2116 16.9998 10.0001 17.0705 9.82655 17.2013C9.65297 17.3322 9.52674 17.516 9.46697 17.7249L8.03897 22.7249C7.98554 22.9115 7.98756 23.1096 8.04478 23.2951C8.10201 23.4805 8.21195 23.6453 8.36122 23.7693C8.51049 23.8934 8.69263 23.9713 8.88542 23.9936C9.07821 24.0159 9.27332 23.9816 9.44697 23.8949L23.447 16.8949V16.8939Z" fill="#D1D5DB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_2358_1380)">
<rect x="2" y="1" width="16" height="16" rx="8" fill="white"/>
<path opacity="0.7" d="M13.5 9H13.505M14 9C14 9.13261 13.9473 9.25979 13.8536 9.35355C13.7598 9.44732 13.6326 9.5 13.5 9.5C13.3674 9.5 13.2402 9.44732 13.1464 9.35355C13.0527 9.25979 13 9.13261 13 9C13 8.86739 13.0527 8.74021 13.1464 8.64645C13.2402 8.55268 13.3674 8.5 13.5 8.5C13.6326 8.5 13.7598 8.55268 13.8536 8.64645C13.9473 8.74021 14 8.86739 14 9Z" stroke="#155EEF" stroke-linecap="round" stroke-linejoin="round"/>
<path opacity="0.6" d="M10 9H10.005M10.5 9C10.5 9.13261 10.4473 9.25979 10.3536 9.35355C10.2598 9.44732 10.1326 9.5 10 9.5C9.86739 9.5 9.74021 9.44732 9.64645 9.35355C9.55268 9.25979 9.5 9.13261 9.5 9C9.5 8.86739 9.55268 8.74021 9.64645 8.64645C9.74021 8.55268 9.86739 8.5 10 8.5C10.1326 8.5 10.2598 8.55268 10.3536 8.64645C10.4473 8.74021 10.5 8.86739 10.5 9Z" stroke="#155EEF" stroke-linecap="round" stroke-linejoin="round"/>
<path opacity="0.3" d="M6.5 9H6.505M7 9C7 9.13261 6.94732 9.25979 6.85355 9.35355C6.75979 9.44732 6.63261 9.5 6.5 9.5C6.36739 9.5 6.24021 9.44732 6.14645 9.35355C6.05268 9.25979 6 9.13261 6 9C6 8.86739 6.05268 8.74021 6.14645 8.64645C6.24021 8.55268 6.36739 8.5 6.5 8.5C6.63261 8.5 6.75979 8.55268 6.85355 8.64645C6.94732 8.74021 7 8.86739 7 9Z" stroke="#155EEF" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_d_2358_1380" x="0" y="0" width="20" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2358_1380"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2358_1380" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 74 KiB

+324
View File
@@ -0,0 +1,324 @@
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 bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
ref={chatFooterRef}
>
<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>
)
}
{/* TODO mars */}
{/* {
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)
@@ -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)
@@ -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;
}
+42
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/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
+63
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'
interface 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)
+91
View File
@@ -0,0 +1,91 @@
.answerIcon {
position: relative;
background: url(./icons/robot.svg);
}
.typeingIcon {
position: absolute;
top: 0px;
left: 0px;
display: flex;
justify-content: center;
align-items: center;
width: 16px;
height: 16px;
background: #FFFFFF;
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
border-radius: 16px;
}
.questionIcon {
background: url(./icons/default-avatar.jpg);
background-size: contain;
border-radius: 50%;
}
.answer::before,
.question::before {
content: '';
position: absolute;
top: 0;
width: 8px;
height: 12px;
}
.answer::before {
left: 0;
background: url(./icons/answer.svg) no-repeat;
}
.answerWrap .itemOperation {
display: none;
}
.answerWrap:hover .itemOperation {
display: flex;
}
.question::before {
right: 0;
background: url(./icons/question.svg) no-repeat;
}
.textArea {
padding-top: 13px;
padding-bottom: 13px;
padding-right: 90px;
border-radius: 12px;
line-height: 20px;
background-color: #fff;
}
.textArea:hover {
background-color: #fff;
}
/* .textArea:focus {
box-shadow: 0px 3px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px rgba(0, 0, 0, 0.05);
} */
.count {
/* display: none; */
padding: 0 2px;
}
.sendBtn {
background: url(./icons/send.svg) center center no-repeat;
}
.sendBtn:hover {
background-image: url(./icons/send-active.svg);
background-color: #EBF5FF;
}
.textArea:focus+div .count {
display: block;
}
.textArea:focus+div .sendBtn {
background-image: url(./icons/send-active.svg);
}
+58
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/chat/answer/tool-detail'
export interface 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)
+28
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)
+106
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)
+56
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)
+146
View File
@@ -0,0 +1,146 @@
import type { TypeWithI18N } from '@/types/base'
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 interface MessageMore {
time: string
tokens: number
latency: number | string
}
export interface 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 interface ToolInfoInThought {
name: string
label: string
input: string
output: string
isFinished: boolean
}
export interface 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 interface 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 interface 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 interface Metadata {
retriever_resources?: CitationItem[]
annotation_reply: {
id: string
account: {
id: string
name: string
}
}
}
export interface MessageEnd {
id: string
metadata: Metadata
files?: FileResponse[]
}
export interface MessageReplace {
id: string
task_id: string
answer: string
conversation_id: string
}
export interface AnnotationReply {
id: string
task_id: string
answer: string
conversation_id: string
annotation_id: string
annotation_author_name: string
}
export interface InputForm {
type: InputVarType
label: string
variable: any
required: boolean
[key: string]: any
}
+32
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
}