This commit is contained in:
Joel
2024-09-18 11:57:48 +08:00
426 changed files with 4961 additions and 2340 deletions

View File

@ -19,3 +19,6 @@ NEXT_TELEMETRY_DISABLED=1
# Disable Upload Image as WebApp icon default is false
NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON=false
# The timeout for the text generation in millisecond
NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=60000

View File

@ -63,7 +63,6 @@ const AppPublisher = ({
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const appDetail = useAppStore(state => state.appDetail)
const [publishedTime, setPublishedTime] = useState<number | undefined>(publishedAt)
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode
const appURL = `${appBaseURL}/${appMode}/${accessToken}`
@ -77,7 +76,6 @@ const AppPublisher = ({
try {
await onPublish?.(modelAndParameter)
setPublished(true)
setPublishedTime(Date.now())
}
catch (e) {
setPublished(false)
@ -133,13 +131,13 @@ const AppPublisher = ({
<div className='w-[336px] bg-white rounded-2xl border-[0.5px] border-gray-200 shadow-xl'>
<div className='p-4 pt-3'>
<div className='flex items-center h-6 text-xs font-medium text-gray-500 uppercase'>
{publishedTime ? t('workflow.common.latestPublished') : t('workflow.common.currentDraftUnpublished')}
{publishedAt ? t('workflow.common.latestPublished') : t('workflow.common.currentDraftUnpublished')}
</div>
{publishedTime
{publishedAt
? (
<div className='flex justify-between items-center h-[18px]'>
<div className='flex items-center mt-[3px] mb-[3px] leading-[18px] text-[13px] font-medium text-gray-700'>
{t('workflow.common.publishedAt')} {formatTimeFromNow(publishedTime)}
{t('workflow.common.publishedAt')} {formatTimeFromNow(publishedAt)}
</div>
<Button
className={`
@ -177,18 +175,18 @@ const AppPublisher = ({
{
published
? t('workflow.common.published')
: publishedTime ? t('workflow.common.update') : t('workflow.common.publish')
: publishedAt ? t('workflow.common.update') : t('workflow.common.publish')
}
</Button>
)
}
</div>
<div className='p-4 pt-3 border-t-[0.5px] border-t-black/5'>
<SuggestedAction disabled={!publishedTime} link={appURL} icon={<PlayCircle />}>{t('workflow.common.runApp')}</SuggestedAction>
<SuggestedAction disabled={!publishedAt} link={appURL} icon={<PlayCircle />}>{t('workflow.common.runApp')}</SuggestedAction>
{appDetail?.mode === 'workflow'
? (
<SuggestedAction
disabled={!publishedTime}
disabled={!publishedAt}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<LeftIndent02 className='w-4 h-4' />}
>
@ -201,16 +199,16 @@ const AppPublisher = ({
setEmbeddingModalOpen(true)
handleTrigger()
}}
disabled={!publishedTime}
disabled={!publishedAt}
icon={<CodeBrowser className='w-4 h-4' />}
>
{t('workflow.common.embedIntoSite')}
</SuggestedAction>
)}
<SuggestedAction disabled={!publishedTime} link='./develop' icon={<FileText className='w-4 h-4' />}>{t('workflow.common.accessAPIReference')}</SuggestedAction>
<SuggestedAction disabled={!publishedAt} link='./develop' icon={<FileText className='w-4 h-4' />}>{t('workflow.common.accessAPIReference')}</SuggestedAction>
{appDetail?.mode === 'workflow' && (
<WorkflowToolConfigureButton
disabled={!publishedTime}
disabled={!publishedAt}
published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published}
workflowAppId={appDetail?.id}

View File

@ -337,7 +337,7 @@ function DetailPanel<T extends ChatConversationFullDetailResponse | CompletionCo
return (
<div ref={ref} className='rounded-xl border-[0.5px] border-gray-200 h-full flex flex-col overflow-auto'>
{/* Panel Header */}
<div className='border-b border-gray-100 py-4 px-6 flex items-center justify-between'>
<div className='border-b border-gray-100 py-4 px-6 flex items-center justify-between bg-components-panel-bg'>
<div>
<div className='text-gray-500 text-[10px] leading-[14px]'>{isChatMode ? t('appLog.detail.conversationId') : t('appLog.detail.time')}</div>
{isChatMode && (
@ -730,7 +730,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
onClose={onCloseDrawer}
mask={isMobile}
footer={null}
panelClassname='mt-16 mx-2 sm:mr-2 mb-4 !p-0 !max-w-[640px] rounded-xl'
panelClassname='mt-16 mx-2 sm:mr-2 mb-4 !p-0 !max-w-[640px] rounded-xl bg-background-gradient-bg-fill-chat-bg-1'
>
<DrawerContext.Provider value={{
onClose: onCloseDrawer,

View File

@ -8,20 +8,19 @@ import type {
ChatConfig,
ChatItem,
} from '../../types'
import { useChatContext } from '../context'
import Operation from './operation'
import AgentContent from './agent-content'
import BasicContent from './basic-content'
import SuggestedQuestions from './suggested-questions'
import More from './more'
import WorkflowProcess from './workflow-process'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
import { AnswerTriangle } from '@/app/components/base/icons/src/vender/solid/general'
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import Citation from '@/app/components/base/chat/chat/citation'
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
import type { AppData } from '@/models/share'
import cn from '@/utils/classnames'
import AnswerIcon from '@/app/components/base/answer-icon'
import cn from '@/utils/classnames'
type AnswerProps = {
item: ChatItem
@ -58,26 +57,24 @@ const Answer: FC<AnswerProps> = ({
} = item
const hasAgentThoughts = !!agent_thoughts?.length
const [containerWidth] = useState(0)
const [containerWidth, setContainerWidth] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const {
config: chatContextConfig,
} = useChatContext()
const getContainerWidth = () => {
if (containerRef.current)
setContainerWidth(containerRef.current?.clientWidth + 16)
}
useEffect(() => {
getContainerWidth()
}, [])
const voiceRef = useRef(chatContextConfig?.text_to_speech?.voice)
const getContentWidth = () => {
if (contentRef.current)
setContentWidth(contentRef.current?.clientWidth)
}
useEffect(() => {
voiceRef.current = chatContextConfig?.text_to_speech?.voice
}
, [chatContextConfig?.text_to_speech?.voice])
useEffect(() => {
if (!responding)
getContentWidth()
@ -86,35 +83,20 @@ const Answer: FC<AnswerProps> = ({
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>
)
}
{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={`group relative pr-10 ${chatAnswerContainerInner}`}>
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<AnswerTriangle className='absolute -left-2 top-0 w-2 h-3 text-gray-100' />
<div
ref={contentRef}
className={cn(
'relative inline-block p-2 max-w-full bg-chat-bubble-bg rounded-2xl border-t border-t-divider-subtle text-sm text-gray-900',
workflowProcess && 'w-full',
)}
className={cn('relative inline-block px-4 py-3 max-w-full bg-gray-100 rounded-b-2xl rounded-tr-2xl text-sm text-gray-900', workflowProcess && 'w-full')}
>
{annotation?.id && (
<div
className='absolute -top-3.5 -right-3.5 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>
)}
{
!responding && (
<Operation

View File

@ -149,7 +149,7 @@ const Operation: FC<OperationProps> = ({
/>
)}
{
!positionRight && annotation?.id && (
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'
>

View File

@ -117,9 +117,9 @@ const Chat: FC<ChatProps> = ({
const userScrolledRef = useRef(false)
const handleScrollToBottom = useCallback(() => {
if (chatContainerRef.current && !userScrolledRef.current)
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current)
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
}, [])
}, [chatList.length])
const handleWindowResize = useCallback(() => {
if (chatContainerRef.current)

View File

@ -73,7 +73,7 @@ const CacheCtrlBtn: FC<Props> = ({
setShowModal(false)
}
return (
<div className={cn(className, 'inline-block')}>
<div className={cn('inline-block', className)}>
<div className='inline-flex p-0.5 space-x-0.5 rounded-lg bg-white border border-gray-100 shadow-md text-gray-500 cursor-pointer'>
{cached
? (
@ -101,7 +101,6 @@ const CacheCtrlBtn: FC<Props> = ({
? (
<Tooltip
popupContent={t('appDebug.feature.annotation.add')}
needsDelay
>
<div
className='p-1 rounded-md hover:bg-[#EEF4FF] hover:text-[#444CE7] cursor-pointer'
@ -115,7 +114,6 @@ const CacheCtrlBtn: FC<Props> = ({
}
<Tooltip
popupContent={t('appDebug.feature.annotation.edit')}
needsDelay
>
<div
className='p-1 cursor-pointer rounded-md hover:bg-black/5'

View File

@ -1,26 +1,42 @@
import type { FC } from 'react'
import { useRef } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { t } from 'i18next'
import { createPortal } from 'react-dom'
import { RiCloseLine, RiExternalLinkLine } from '@remixicon/react'
import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import { randomString } from '@/utils'
import Toast from '@/app/components/base/toast'
type ImagePreviewProps = {
url: string
title: string
onCancel: () => void
}
const isBase64 = (str: string): boolean => {
try {
return btoa(atob(str)) === str
}
catch (err) {
return false
}
}
const ImagePreview: FC<ImagePreviewProps> = ({
url,
title,
onCancel,
}) => {
const selector = useRef(`copy-tooltip-${randomString(4)}`)
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const imgRef = useRef<HTMLImageElement>(null)
const dragStartRef = useRef({ x: 0, y: 0 })
const [isCopied, setIsCopied] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const openInNewTab = () => {
// Open in a new window, considering the case when the page is inside an iframe
if (url.startsWith('http')) {
if (url.startsWith('http') || url.startsWith('https')) {
window.open(url, '_blank')
}
else if (url.startsWith('data:image')) {
@ -29,34 +45,224 @@ const ImagePreview: FC<ImagePreviewProps> = ({
win?.document.write(`<img src="${url}" alt="${title}" />`)
}
else {
console.error('Unable to open image', url)
Toast.notify({
type: 'error',
message: `Unable to open image: ${url}`,
})
}
}
const downloadImage = () => {
// Open in a new window, considering the case when the page is inside an iframe
if (url.startsWith('http') || url.startsWith('https')) {
const a = document.createElement('a')
a.href = url
a.download = title
a.click()
}
else if (url.startsWith('data:image')) {
// Base64 image
const a = document.createElement('a')
a.href = url
a.download = title
a.click()
}
else {
Toast.notify({
type: 'error',
message: `Unable to open image: ${url}`,
})
}
}
const zoomIn = () => {
setScale(prevScale => Math.min(prevScale * 1.2, 15))
}
const zoomOut = () => {
setScale((prevScale) => {
const newScale = Math.max(prevScale / 1.2, 0.5)
if (newScale === 1)
setPosition({ x: 0, y: 0 }) // Reset position when fully zoomed out
return newScale
})
}
const imageTobase64ToBlob = (base64: string, type = 'image/png'): Blob => {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++)
byteNumbers[i] = slice.charCodeAt(i)
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, { type })
}
const imageCopy = useCallback(() => {
const shareImage = async () => {
try {
const base64Data = url.split(',')[1]
const blob = imageTobase64ToBlob(base64Data, 'image/png')
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
])
setIsCopied(true)
Toast.notify({
type: 'success',
message: t('common.operation.imageCopied'),
})
}
catch (err) {
console.error('Failed to copy image:', err)
const link = document.createElement('a')
link.href = url
link.download = `${title}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
Toast.notify({
type: 'info',
message: t('common.operation.imageDownloaded'),
})
}
}
shareImage()
}, [title, url])
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
if (e.deltaY < 0)
zoomIn()
else
zoomOut()
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (scale > 1) {
setIsDragging(true)
dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y }
}
}, [scale, position])
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isDragging && scale > 1) {
const deltaX = e.clientX - dragStartRef.current.x
const deltaY = e.clientY - dragStartRef.current.y
// Calculate boundaries
const imgRect = imgRef.current?.getBoundingClientRect()
const containerRect = imgRef.current?.parentElement?.getBoundingClientRect()
if (imgRect && containerRect) {
const maxX = (imgRect.width * scale - containerRect.width) / 2
const maxY = (imgRect.height * scale - containerRect.height) / 2
setPosition({
x: Math.max(-maxX, Math.min(maxX, deltaX)),
y: Math.max(-maxY, Math.min(maxY, deltaY)),
})
}
}
}, [isDragging, scale])
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mouseup', handleMouseUp)
}
}, [handleMouseUp])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape')
onCancel()
}
window.addEventListener('keydown', handleKeyDown)
// Set focus to the container element
if (containerRef.current)
containerRef.current.focus()
// Cleanup function
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [onCancel])
return createPortal(
<div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]' onClick={e => e.stopPropagation()}>
<div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000] image-preview-container'
onClick={e => e.stopPropagation()}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor: scale > 1 ? 'move' : 'default' }}
tabIndex={-1}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
alt={title}
src={url}
src={isBase64(url) ? `data:image/png;base64,${url}` : url}
className='max-w-full max-h-full'
style={{
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease-in-out',
}}
/>
<div
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/8 rounded-lg backdrop-blur-[2px] cursor-pointer'
onClick={onCancel}
>
<RiCloseLine className='w-4 h-4 text-white' />
</div>
<Tooltip
selector={selector.current}
content={(t('common.operation.openInNewTab') ?? 'Open in new tab')}
className='z-10'
>
<Tooltip popupContent={t('common.operation.copyImage')}>
<div className='absolute top-6 right-48 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
onClick={imageCopy}>
{isCopied
? <RiFileCopyLine className='w-4 h-4 text-green-500'/>
: <RiFileCopyLine className='w-4 h-4 text-gray-500'/>}
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.zoomOut')}>
<div className='absolute top-6 right-40 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
onClick={zoomOut}>
<RiZoomOutLine className='w-4 h-4 text-gray-500'/>
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.zoomIn')}>
<div className='absolute top-6 right-32 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
onClick={zoomIn}>
<RiZoomInLine className='w-4 h-4 text-gray-500'/>
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.download')}>
<div className='absolute top-6 right-24 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
onClick={downloadImage}>
<RiDownloadCloud2Line className='w-4 h-4 text-gray-500'/>
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.openInNewTab')}>
<div className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
onClick={openInNewTab}>
<RiAddBoxLine className='w-4 h-4 text-gray-500'/>
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.close')}>
<div
className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
onClick={openInNewTab}
>
<RiExternalLinkLine className='w-4 h-4 text-white' />
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/8 rounded-lg backdrop-blur-[2px] cursor-pointer'
onClick={onCancel}>
<RiCloseLine className='w-4 h-4 text-gray-500'/>
</div>
</Tooltip>
</div>,

View File

@ -5,6 +5,7 @@ import RemarkMath from 'remark-math'
import RemarkBreaks from 'remark-breaks'
import RehypeKatex from 'rehype-katex'
import RemarkGfm from 'remark-gfm'
import RehypeRaw from 'rehype-raw'
import SyntaxHighlighter from 'react-syntax-highlighter'
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
import type { RefObject } from 'react'
@ -18,6 +19,7 @@ import ImageGallery from '@/app/components/base/image-gallery'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import VideoGallery from '@/app/components/base/video-gallery'
import AudioGallery from '@/app/components/base/audio-gallery'
import SVGRenderer from '@/app/components/base/svg-gallery'
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
const capitalizationLanguageNameMap: Record<string, string> = {
@ -40,6 +42,7 @@ const capitalizationLanguageNameMap: Record<string, string> = {
powershell: 'PowerShell',
json: 'JSON',
latex: 'Latex',
svg: 'SVG',
}
const getCorrectCapitalizationLanguageName = (language: string) => {
if (!language)
@ -107,6 +110,7 @@ const useLazyLoad = (ref: RefObject<Element>): boolean => {
// Error: Minified React error 185;
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
// or use the non-minified dev environment for full errors and additional helpful warnings.
const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }) => {
const [isSVG, setIsSVG] = useState(true)
const match = /language-(\w+)/.exec(className || '')
@ -134,7 +138,7 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
>
<div className='text-[13px] text-gray-500 font-normal'>{languageShowName}</div>
<div style={{ display: 'flex' }}>
{language === 'mermaid' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
{language === 'mermaid' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG}/>}
<CopyBtn
className='mr-1'
value={String(children).replace(/\n$/, '')}
@ -144,12 +148,10 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
</div>
{(language === 'mermaid' && isSVG)
? (<Flowchart PrimitiveCode={String(children).replace(/\n$/, '')} />)
: (
(language === 'echarts')
? (<div style={{ minHeight: '250px', minWidth: '250px' }}><ErrorBoundary><ReactEcharts
option={chartData}
>
</ReactEcharts></ErrorBoundary></div>)
: (language === 'echarts'
? (<div style={{ minHeight: '350px', minWidth: '700px' }}><ErrorBoundary><ReactEcharts option={chartData} /></ErrorBoundary></div>)
: (language === 'svg'
? (<ErrorBoundary><SVGRenderer content={String(children).replace(/\n$/, '')} /></ErrorBoundary>)
: (<SyntaxHighlighter
{...props}
style={atelierHeathLight}
@ -162,17 +164,12 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
PreTag="div"
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>))}
</SyntaxHighlighter>)))}
</div>
)
: (
<code {...props} className={className}>
{children}
</code>
)
: (<code {...props} className={className}>{children}</code>)
}, [chartData, children, className, inline, isSVG, language, languageShowName, match, props])
})
CodeBlock.displayName = 'CodeBlock'
const VideoBlock: CodeComponent = memo(({ node }) => {
@ -230,6 +227,7 @@ export function Markdown(props: { content: string; className?: string }) {
remarkPlugins={[[RemarkGfm, RemarkMath, { singleDollarTextMath: false }], RemarkBreaks]}
rehypePlugins={[
RehypeKatex,
RehypeRaw as any,
// The Rehype plug-in is used to remove the ref attribute of an element
() => {
return (tree) => {
@ -244,6 +242,7 @@ export function Markdown(props: { content: string; className?: string }) {
}
},
]}
disallowedElements={['script', 'iframe', 'head', 'html', 'meta', 'link', 'style', 'body']}
components={{
code: CodeBlock,
img: Img,
@ -266,19 +265,23 @@ export function Markdown(props: { content: string; className?: string }) {
// This can happen when a component attempts to access an undefined object that references an unregistered map, causing the program to crash.
export default class ErrorBoundary extends Component {
constructor(props) {
constructor(props: any) {
super(props)
this.state = { hasError: false }
}
componentDidCatch(error, errorInfo) {
componentDidCatch(error: any, errorInfo: any) {
this.setState({ hasError: true })
console.error(error, errorInfo)
}
render() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (this.state.hasError)
return <div>Oops! ECharts reported a runtime error. <br />(see the browser console for more information)</div>
return <div>Oops! An error occurred. This could be due to an ECharts runtime error or invalid SVG content. <br />(see the browser console for more information)</div>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return this.props.children
}
}

View File

@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from 'react'
import { SVG } from '@svgdotjs/svg.js'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
export const SVGRenderer = ({ content }: { content: string }) => {
const svgRef = useRef<HTMLDivElement>(null)
const [imagePreview, setImagePreview] = useState('')
const [windowSize, setWindowSize] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
})
const svgToDataURL = (svgElement: Element): string => {
const svgString = new XMLSerializer().serializeToString(svgElement)
const base64String = Buffer.from(svgString).toString('base64')
return `data:image/svg+xml;base64,${base64String}`
}
useEffect(() => {
const handleResize = () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight })
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
useEffect(() => {
if (svgRef.current) {
try {
svgRef.current.innerHTML = ''
const draw = SVG().addTo(svgRef.current).size('100%', '100%')
const parser = new DOMParser()
const svgDoc = parser.parseFromString(content, 'image/svg+xml')
const svgElement = svgDoc.documentElement
if (!(svgElement instanceof SVGElement))
throw new Error('Invalid SVG content')
const originalWidth = parseInt(svgElement.getAttribute('width') || '400', 10)
const originalHeight = parseInt(svgElement.getAttribute('height') || '600', 10)
const scale = Math.min(windowSize.width / originalWidth, windowSize.height / originalHeight, 1)
const scaledWidth = originalWidth * scale
const scaledHeight = originalHeight * scale
draw.size(scaledWidth, scaledHeight)
const rootElement = draw.svg(content)
rootElement.scale(scale)
rootElement.click(() => {
setImagePreview(svgToDataURL(svgElement as Element))
})
}
catch (error) {
if (svgRef.current)
svgRef.current.innerHTML = 'Error rendering SVG. Wait for the image content to complete.'
}
}
}, [content, windowSize])
return (
<>
<div ref={svgRef} style={{
width: '100%',
height: '100%',
minHeight: '300px',
maxHeight: '80vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
}} />
{imagePreview && (<ImagePreview url={imagePreview} title='Preview' onCancel={() => setImagePreview('')} />)}
</>
)
}
export default SVGRenderer

View File

@ -413,3 +413,109 @@ Workflow applications offers non-session support and is ideal for translation, a
</CodeGroup>
</Col>
</Row>
---
<Heading
url='/workflows/logs'
method='GET'
title='Get workflow logs'
name='#Get-Workflow-Logs'
/>
<Row>
<Col>
Returns worklfow logs, with the first page returning the latest `{limit}` messages, i.e., in reverse order.
### Query
<Properties>
<Property name='keyword' type='string' key='keyword'>
Keyword to search
</Property>
<Property name='status' type='string' key='status'>
succeeded/failed/stopped
</Property>
<Property name='page' type='int' key='page'>
current page, default is 1.
</Property>
<Property name='limit' type='int' key='limit'>
How many chat history messages to return in one request, default is 20.
</Property>
</Properties>
### Response
- `page` (int) Current page
- `limit` (int) Number of returned items, if input exceeds system limit, returns system limit amount
- `total` (int) Number of total items
- `has_more` (bool) Whether there is a next page
- `data` (array[object]) Log list
- `id` (string) ID
- `workflow_run` (object) Workflow run
- `id` (string) ID
- `version` (string) Version
- `status` (string) status of execution, `running` / `succeeded` / `failed` / `stopped`
- `error` (string) Optional reason of error
- `elapsed_time` (float) total seconds to be used
- `total_tokens` (int) tokens to be used
- `total_steps` (int) default 0
- `created_at` (timestamp) start time
- `finished_at` (timestamp) end time
- `created_from` (string) Created from
- `created_by_role` (string) Created by role
- `created_by_account` (string) Optional Created by account
- `created_by_end_user` (object) Created by end user
- `id` (string) ID
- `type` (string) Type
- `is_anonymous` (bool) Is anonymous
- `session_id` (string) Session ID
- `created_at` (timestamp) create time
</Col>
<Col sticky>
<CodeGroup title="Request" tag="GET" label="/workflows/logs" targetCode={`curl -X GET '${props.appDetail.api_base_url}/workflows/logs'\\\n --header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/workflows/logs?limit=1'
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### Response Example
<CodeGroup title="Response">
```json {{ title: 'Response' }}
{
"page": 1,
"limit": 1,
"total": 7,
"has_more": true,
"data": [
{
"id": "e41b93f1-7ca2-40fd-b3a8-999aeb499cc0",
"workflow_run": {
"id": "c0640fc8-03ef-4481-a96c-8a13b732a36e",
"version": "2024-08-01 12:17:09.771832",
"status": "succeeded",
"error": null,
"elapsed_time": 1.3588523610014818,
"total_tokens": 0,
"total_steps": 3,
"created_at": 1726139643,
"finished_at": 1726139644
},
"created_from": "service-api",
"created_by_role": "end_user",
"created_by_account": null,
"created_by_end_user": {
"id": "7f7d9117-dd9d-441d-8970-87e5e7e687a3",
"type": "service_api",
"is_anonymous": false,
"session_id": "abc-123"
},
"created_at": 1726139644
}
]
}
```
</CodeGroup>
</Col>
</Row>

View File

@ -409,3 +409,109 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
</CodeGroup>
</Col>
</Row>
---
<Heading
url='/workflows/logs'
method='GET'
title='获取 workflow 日志'
name='#Get-Workflow-Logs'
/>
<Row>
<Col>
倒序返回workflow日志
### Query
<Properties>
<Property name='keyword' type='string' key='keyword'>
关键字
</Property>
<Property name='status' type='string' key='status'>
执行状态 succeeded/failed/stopped
</Property>
<Property name='page' type='int' key='page'>
当前页码, 默认1.
</Property>
<Property name='limit' type='int' key='limit'>
每页条数, 默认20.
</Property>
</Properties>
### Response
- `page` (int) 当前页码
- `limit` (int) 每页条数
- `total` (int) 总条数
- `has_more` (bool) 是否还有更多数据
- `data` (array[object]) 当前页码的数据
- `id` (string) 标识
- `workflow_run` (object) Workflow 执行日志
- `id` (string) 标识
- `version` (string) 版本
- `status` (string) 执行状态, `running` / `succeeded` / `failed` / `stopped`
- `error` (string) (可选) 错误
- `elapsed_time` (float) 耗时,单位秒
- `total_tokens` (int) 消耗的token数量
- `total_steps` (int) 执行步骤长度
- `created_at` (timestamp) 开始时间
- `finished_at` (timestamp) 结束时间
- `created_from` (string) 来源
- `created_by_role` (string) 角色
- `created_by_account` (string) (可选) 帐号
- `created_by_end_user` (object) 用户
- `id` (string) 标识
- `type` (string) 类型
- `is_anonymous` (bool) 是否匿名
- `session_id` (string) 会话标识
- `created_at` (timestamp) 创建时间
</Col>
<Col sticky>
<CodeGroup title="Request" tag="GET" label="/workflows/logs" targetCode={`curl -X GET '${props.appDetail.api_base_url}/workflows/logs'\\\n --header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/workflows/logs?limit=1'
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### Response Example
<CodeGroup title="Response">
```json {{ title: 'Response' }}
{
"page": 1,
"limit": 1,
"total": 7,
"has_more": true,
"data": [
{
"id": "e41b93f1-7ca2-40fd-b3a8-999aeb499cc0",
"workflow_run": {
"id": "c0640fc8-03ef-4481-a96c-8a13b732a36e",
"version": "2024-08-01 12:17:09.771832",
"status": "succeeded",
"error": null,
"elapsed_time": 1.3588523610014818,
"total_tokens": 0,
"total_steps": 3,
"created_at": 1726139643,
"finished_at": 1726139644
},
"created_from": "service-api",
"created_by_role": "end_user",
"created_by_account": null,
"created_by_end_user": {
"id": "7f7d9117-dd9d-441d-8970-87e5e7e687a3",
"type": "service_api",
"is_anonymous": false,
"session_id": "abc-123"
},
"created_at": 1726139644
}
]
}
```
</CodeGroup>
</Col>
</Row>

View File

@ -192,12 +192,12 @@ const ModelLoadBalancingEntryModal: FC<ModelModalProps> = ({
})
const getSecretValues = useCallback((v: FormValue) => {
return secretFormSchemas.reduce((prev, next) => {
if (v[next.variable] === initialFormSchemasValue[next.variable])
if (isEditMode && v[next.variable] && v[next.variable] === initialFormSchemasValue[next.variable])
prev[next.variable] = '[__HIDDEN__]'
return prev
}, {} as Record<string, string>)
}, [initialFormSchemasValue, secretFormSchemas])
}, [initialFormSchemasValue, isEditMode, secretFormSchemas])
// const handleValueChange = ({ __model_type, __model_name, ...v }: FormValue) => {
const handleValueChange = (v: FormValue) => {
@ -214,6 +214,7 @@ const ModelLoadBalancingEntryModal: FC<ModelModalProps> = ({
...value,
...getSecretValues(value),
},
entry?.id,
)
if (res.status === ValidatedStatus.Success) {
// notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })

View File

@ -56,14 +56,14 @@ export const validateCredentials = async (predefined: boolean, provider: string,
}
}
export const validateLoadBalancingCredentials = async (predefined: boolean, provider: string, v: FormValue): Promise<{
export const validateLoadBalancingCredentials = async (predefined: boolean, provider: string, v: FormValue, id?: string): Promise<{
status: ValidatedStatus
message?: string
}> => {
const { __model_name, __model_type, ...credentials } = v
try {
const res = await validateModelLoadBalancingCredentials({
url: `/workspaces/current/model-providers/${provider}/models/load-balancing-configs/credentials-validate`,
url: `/workspaces/current/model-providers/${provider}/models/load-balancing-configs/${id ? `${id}/` : ''}credentials-validate`,
body: {
model: __model_name,
model_type: __model_type,

View File

@ -269,8 +269,10 @@ const Result: FC<IResultProps> = ({
}))
},
onWorkflowFinished: ({ data }) => {
if (isTimeout)
if (isTimeout) {
notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') })
return
}
if (data.error) {
notify({ type: 'error', message: data.error })
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
@ -326,8 +328,10 @@ const Result: FC<IResultProps> = ({
setCompletionRes(res.join(''))
},
onCompleted: () => {
if (isTimeout)
if (isTimeout) {
notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') })
return
}
setRespondingFalse()
setMessageId(tempMessageId)
onCompleted(getCompletionRes(), taskId, true)
@ -338,8 +342,10 @@ const Result: FC<IResultProps> = ({
setCompletionRes(res.join(''))
},
onError() {
if (isTimeout)
if (isTimeout) {
notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') })
return
}
setRespondingFalse()
onCompleted(getCompletionRes(), taskId, false)
isEnd = true

View File

@ -1,4 +1,4 @@
import type { FC } from 'react'
import type { FC, FormEvent } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
@ -41,11 +41,16 @@ const RunOnce: FC<IRunOnceProps> = ({
onInputsChange(newInputs)
}
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
onSend()
}
return (
<div className="">
<section>
{/* input form */}
<form>
<form onSubmit={onSubmit}>
{promptConfig.prompt_variables.map(item => (
<div className='w-full mt-4' key={item.key}>
<label className='text-gray-900 text-sm font-medium'>{item.name}</label>
@ -67,12 +72,6 @@ const RunOnce: FC<IRunOnceProps> = ({
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
value={inputs[item.key]}
onChange={(e) => { onInputsChange({ ...inputs, [item.key]: e.target.value }) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onSend()
}
}}
maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN}
/>
)}
@ -127,8 +126,8 @@ const RunOnce: FC<IRunOnceProps> = ({
<span className='text-[13px]'>{t('common.operation.clear')}</span>
</Button>
<Button
type='submit'
variant="primary"
onClick={onSend}
disabled={false}
>
<PlayIcon className="shrink-0 w-4 h-4 mr-1" aria-hidden="true" />

View File

@ -90,7 +90,6 @@ const Blocks = ({
)}
</div>
)}
needsDelay
>
<div className='group/item flex items-center w-full pl-3 pr-1 h-8 rounded-lg hover:bg-gray-50 cursor-pointer'>
<BlockIcon

View File

@ -70,15 +70,16 @@ export const useShortcuts = (): void => {
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
const { showDebugAndPreviewPanel, showInputsPanel } = workflowStore.getState()
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel && !showInputsPanel) {
const { showDebugAndPreviewPanel } = workflowStore.getState()
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
e.preventDefault()
handleNodesCopy()
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, (e) => {
if (shouldHandleShortcut(e)) {
const { showDebugAndPreviewPanel } = workflowStore.getState()
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
e.preventDefault()
handleNodesPaste()
}
@ -99,7 +100,8 @@ export const useShortcuts = (): void => {
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.z`, (e) => {
if (shouldHandleShortcut(e)) {
const { showDebugAndPreviewPanel } = workflowStore.getState()
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
e.preventDefault()
workflowHistoryShortcutsEnabled && handleHistoryBack()
}

View File

@ -1,9 +1,12 @@
import { useMemo } from 'react'
import { useNodes } from 'reactflow'
import { capitalize } from 'lodash-es'
import { useTranslation } from 'react-i18next'
import { RiErrorWarningFill } from '@remixicon/react'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import type {
CommonNodeType,
Node,
ValueSelector,
VarType,
} from '@/app/components/workflow/types'
@ -11,65 +14,82 @@ import { BlockEnum } from '@/app/components/workflow/types'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
type VariableTagProps = {
valueSelector: ValueSelector
varType: VarType
isShort?: boolean
availableNodes?: Node[]
}
const VariableTag = ({
valueSelector,
varType,
isShort,
availableNodes,
}: VariableTagProps) => {
const nodes = useNodes<CommonNodeType>()
const node = useMemo(() => {
if (isSystemVar(valueSelector))
return nodes.find(node => node.data.type === BlockEnum.Start)
if (isSystemVar(valueSelector)) {
const startNode = availableNodes?.find(n => n.data.type === BlockEnum.Start)
if (startNode)
return startNode
}
return getNodeInfoById(availableNodes || nodes, valueSelector[0])
}, [nodes, valueSelector, availableNodes])
return nodes.find(node => node.id === valueSelector[0])
}, [nodes, valueSelector])
const isEnv = isENV(valueSelector)
const isChatVar = isConversationVar(valueSelector)
const isValid = Boolean(node) || isEnv || isChatVar
const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.')
const { t } = useTranslation()
return (
<div className='inline-flex items-center px-1.5 max-w-full h-6 text-xs rounded-md border-[0.5px] border-[rgba(16, 2440,0.08)] bg-white shadow-xs'>
{!isEnv && !isChatVar && (
<>
<Tooltip popupContent={!isValid && t('workflow.errorMsg.invalidVariable')}>
<div className={cn('inline-flex items-center px-1.5 max-w-full h-6 text-xs rounded-md border-[0.5px] border-[rgba(16, 2440,0.08)] bg-white shadow-xs',
!isValid && 'border-red-400 !bg-[#FEF3F2]',
)}>
{(!isEnv && !isChatVar && <>
{node && (
<VarBlockIcon
className='shrink-0 mr-0.5 text-text-secondary'
type={node!.data.type}
/>
<>
<VarBlockIcon
type={BlockEnum.Start}
/>
<div
className='max-w-[60px] truncate text-text-secondary font-medium'
title={node?.data.title}
>
{node?.data.title}
</div>
</>
)}
<div
className='max-w-[60px] truncate text-text-secondary font-medium'
title={node?.data.title}
>
{node?.data.title}
</div>
<Line3 className='shrink-0 mx-0.5' />
<Variable02 className='shrink-0 mr-0.5 w-3.5 h-3.5 text-text-accent' />
</>
)}
{isEnv && <Env className='shrink-0 mr-0.5 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div
className={cn('truncate text-text-accent font-medium', (isEnv || isChatVar) && 'text-text-secondary')}
title={variableName}
>
{variableName}
</>)}
{isEnv && <Env className='shrink-0 mr-0.5 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div
className={cn('truncate text-text-accent font-medium', (isEnv || isChatVar) && 'text-text-secondary')}
title={variableName}
>
{variableName}
</div>
{
varType && (
<div className='shrink-0 ml-0.5 text-text-tertiary'>{capitalize(varType)}</div>
)
}
{!isValid && <RiErrorWarningFill className='ml-0.5 w-3 h-3 text-[#D92D20]' />}
</div>
{
!isShort && varType && (
<div className='shrink-0 ml-0.5 text-text-tertiary'>{capitalize(varType)}</div>
)
}
</div>
</Tooltip>
)
}

View File

@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCloseLine,
RiErrorWarningFill,
} from '@remixicon/react'
import produce from 'immer'
import { useStoreApi } from 'reactflow'
import RemoveButton from '../remove-button'
import useAvailableVarList from '../../hooks/use-available-var-list'
import VarReferencePopup from './var-reference-popup'
import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from './utils'
import ConstantField from './constant-field'
@ -27,13 +29,14 @@ import {
} from '@/app/components/base/portal-to-follow-elem'
import {
useIsChatMode,
useWorkflow,
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
import AddButton from '@/app/components/base/button/add-button'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
const TRIGGER_DEFAULT_WIDTH = 227
type Props = {
@ -70,7 +73,7 @@ const VarReferencePicker: FC<Props> = ({
onlyLeafNodeVar,
filterVar = () => true,
availableNodes: passedInAvailableNodes,
availableVars,
availableVars: passedInAvailableVars,
isAddBtnTrigger,
schema,
valueTypePlaceHolder,
@ -84,11 +87,12 @@ const VarReferencePicker: FC<Props> = ({
} = store.getState()
const isChatMode = useIsChatMode()
const { getTreeLeafNodes, getBeforeNodesInSameBranch } = useWorkflow()
const { getCurrentVariableType, getNodeAvailableVars } = useWorkflowVariables()
const availableNodes = useMemo(() => {
return passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranch(nodeId))
}, [getBeforeNodesInSameBranch, getTreeLeafNodes, nodeId, onlyLeafNodeVar, passedInAvailableNodes])
const { getCurrentVariableType } = useWorkflowVariables()
const { availableNodes, availableVars } = useAvailableVarList(nodeId, {
onlyLeafNodeVar,
passedInAvailableNodes,
filterVar,
})
const startNode = availableNodes.find((node: any) => {
return node.data.type === BlockEnum.Start
})
@ -107,19 +111,8 @@ const VarReferencePicker: FC<Props> = ({
const [varKindType, setVarKindType] = useState<VarKindType>(defaultVarKindType)
const isConstant = isSupportConstantValue && varKindType === VarKindType.constant
const outputVars = useMemo(() => {
if (availableVars)
return availableVars
const vars = getNodeAvailableVars({
parentNode: iterationNode,
beforeNodes: availableNodes,
isChatMode,
filterVar,
})
return vars
}, [iterationNode, availableNodes, isChatMode, filterVar, availableVars, getNodeAvailableVars])
const outputVars = useMemo(() => (passedInAvailableVars || availableVars), [passedInAvailableVars, availableVars])
const [open, setOpen] = useState(false)
useEffect(() => {
@ -194,7 +187,7 @@ const VarReferencePicker: FC<Props> = ({
const handleVarReferenceChange = useCallback((value: ValueSelector, varInfo: Var) => {
// sys var not passed to backend
const newValue = produce(value, (draft) => {
if (draft[1] && draft[1].startsWith('sys')) {
if (draft[1] && draft[1].startsWith('sys.')) {
draft.shift()
const paths = draft[0].split('.')
paths.forEach((p, i) => {
@ -221,8 +214,16 @@ const VarReferencePicker: FC<Props> = ({
isConstant: !!isConstant,
})
const isEnv = isENV(value as ValueSelector)
const isChatVar = isConversationVar(value as ValueSelector)
const { isEnv, isChatVar, isValidVar } = useMemo(() => {
const isEnv = isENV(value as ValueSelector)
const isChatVar = isConversationVar(value as ValueSelector)
const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar
return {
isEnv,
isChatVar,
isValidVar,
}
}, [value, outputVarNode])
// 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff
const availableWidth = triggerWidth - 56
@ -301,39 +302,42 @@ const VarReferencePicker: FC<Props> = ({
className='grow h-full'
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center pl-1 py-1 rounded-lg bg-gray-100')}>
<div className={cn('h-full items-center px-1.5 rounded-[5px]', hasValue ? 'bg-white inline-flex' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && !isEnv && !isChatVar && (
<div className='flex items-center'>
<div className='p-[1px]'>
<VarBlockIcon
className='!text-gray-900'
type={outputVarNode?.type || BlockEnum.Start}
/>
<Tooltip popupContent={!isValidVar && hasValue && t('workflow.errorMsg.invalidVariable')}>
<div className={cn('h-full items-center px-1.5 rounded-[5px]', hasValue ? 'bg-white inline-flex' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && !isEnv && !isChatVar && (
<div className='flex items-center'>
<div className='px-[1px] h-3'>
{outputVarNode?.type && <VarBlockIcon
className='!text-gray-900'
type={outputVarNode.type}
/>}
</div>
<div className='mx-0.5 text-xs font-medium text-gray-700 truncate' title={outputVarNode?.title} style={{
maxWidth: maxNodeNameWidth,
}}>{outputVarNode?.title}</div>
<Line3 className='mr-0.5'></Line3>
</div>
<div className='mx-0.5 text-xs font-medium text-gray-700 truncate' title={outputVarNode?.title} style={{
maxWidth: maxNodeNameWidth,
}}>{outputVarNode?.title}</div>
<Line3 className='mr-0.5'></Line3>
)}
<div className='flex items-center text-primary-600'>
{!hasValue && <Variable02 className='w-3.5 h-3.5' />}
{isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div className={cn('ml-0.5 text-xs font-medium truncate', (isEnv || isChatVar) && '!text-text-secondary')} title={varName} style={{
maxWidth: maxVarNameWidth,
}}>{varName}</div>
</div>
)}
<div className='flex items-center text-primary-600'>
{!hasValue && <Variable02 className='w-3.5 h-3.5' />}
{isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div className={cn('ml-0.5 text-xs font-medium truncate', (isEnv || isChatVar) && '!text-text-secondary')} title={varName} style={{
maxWidth: maxVarNameWidth,
}}>{varName}</div>
</div>
<div className='ml-0.5 text-xs font-normal text-gray-500 capitalize truncate' title={type} style={{
maxWidth: maxTypeWidth,
}}>{type}</div>
</>
)
: <div className='text-[13px] font-normal text-gray-400'>{t('workflow.common.setVarValuePlaceholder')}</div>}
</div>
<div className='ml-0.5 text-xs font-normal text-gray-500 capitalize truncate' title={type} style={{
maxWidth: maxTypeWidth,
}}>{type}</div>
{!isValidVar && <RiErrorWarningFill className='ml-0.5 w-3 h-3 text-[#D92D20]' />}
</>
)
: <div className='text-[13px] font-normal text-gray-400'>{t('workflow.common.setVarValuePlaceholder')}</div>}
</div>
</Tooltip>
</div>
</VarPickerWrap>

View File

@ -4,12 +4,13 @@ import {
useWorkflow,
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import type { Node, ValueSelector, Var } from '@/app/components/workflow/types'
type Params = {
onlyLeafNodeVar?: boolean
hideEnv?: boolean
hideChatVar?: boolean
filterVar: (payload: Var, selector: ValueSelector) => boolean
passedInAvailableNodes?: Node[]
}
const useAvailableVarList = (nodeId: string, {
@ -17,6 +18,7 @@ const useAvailableVarList = (nodeId: string, {
filterVar,
hideEnv,
hideChatVar,
passedInAvailableNodes,
}: Params = {
onlyLeafNodeVar: false,
filterVar: () => true,
@ -25,7 +27,7 @@ const useAvailableVarList = (nodeId: string, {
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()
const availableNodes = onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranch(nodeId)
const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranch(nodeId))
const {
parentNode: iterationNode,

View File

@ -222,6 +222,7 @@ const ConditionItem = ({
<VariableTag
valueSelector={condition.variable_selector || []}
varType={condition.varType}
availableNodes={availableNodes}
/>
)}

View File

@ -11,6 +11,7 @@ import {
RiArrowDownSLine,
RiMenu4Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import NodePanel from './node'
import {
BlockEnum,
@ -37,7 +38,7 @@ type TracingNodeProps = {
hideNodeProcessDetail?: boolean
}
function buildLogTree(nodes: NodeTracing[]): TracingNodeProps[] {
function buildLogTree(nodes: NodeTracing[], t: (key: string) => string): TracingNodeProps[] {
const rootNodes: TracingNodeProps[] = []
const parallelStacks: { [key: string]: TracingNodeProps } = {}
const levelCounts: { [key: string]: number } = {}
@ -58,7 +59,7 @@ function buildLogTree(nodes: NodeTracing[]): TracingNodeProps[] {
const parentTitle = parentId ? parallelStacks[parentId]?.parallelTitle : ''
const levelNumber = parentTitle ? parseInt(parentTitle.split('-')[1]) + 1 : 1
const letter = parallelChildCounts[levelKey]?.size > 1 ? String.fromCharCode(64 + levelCounts[levelKey]) : ''
return `PARALLEL-${levelNumber}${letter}`
return `${t('workflow.common.parallel')}-${levelNumber}${letter}`
}
const getBranchTitle = (parentId: string | null, branchNum: number): string => {
@ -67,7 +68,7 @@ function buildLogTree(nodes: NodeTracing[]): TracingNodeProps[] {
const levelNumber = parentTitle ? parseInt(parentTitle.split('-')[1]) + 1 : 1
const letter = parallelChildCounts[levelKey]?.size > 1 ? String.fromCharCode(64 + levelCounts[levelKey]) : ''
const branchLetter = String.fromCharCode(64 + branchNum)
return `BRANCH-${levelNumber}${letter}-${branchLetter}`
return `${t('workflow.common.branch')}-${levelNumber}${letter}-${branchLetter}`
}
// Count parallel children (for figuring out if we need to use letters)
@ -163,7 +164,8 @@ const TracingPanel: FC<TracingPanelProps> = ({
hideNodeInfo = false,
hideNodeProcessDetail = false,
}) => {
const treeNodes = buildLogTree(list)
const { t } = useTranslation()
const treeNodes = buildLogTree(list, t)
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(new Set())
const [hoveredParallel, setHoveredParallel] = useState<string | null>(null)

View File

@ -43,6 +43,7 @@ const LocaleLayout = ({
data-public-sentry-dsn={process.env.NEXT_PUBLIC_SENTRY_DSN}
data-public-maintenance-notice={process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE}
data-public-site-about={process.env.NEXT_PUBLIC_SITE_ABOUT}
data-public-text-generation-timeout-ms={process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS}
>
<Topbar />
<BrowserInitor>

View File

@ -246,6 +246,13 @@ Thought: {{agent_scratchpad}}
export const VAR_REGEX = /\{\{(#[a-zA-Z0-9_-]{1,50}(\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}/gi
export const TEXT_GENERATION_TIMEOUT_MS = 60000
export let textGenerationTimeoutMs = 60000
if (process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS && process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS !== '')
textGenerationTimeoutMs = parseInt(process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS)
else if (globalThis.document?.body?.getAttribute('data-public-text-generation-timeout-ms') && globalThis.document.body.getAttribute('data-public-text-generation-timeout-ms') !== '')
textGenerationTimeoutMs = parseInt(globalThis.document.body.getAttribute('data-public-text-generation-timeout-ms') as string)
export const TEXT_GENERATION_TIMEOUT_MS = textGenerationTimeoutMs
export const DISABLE_UPLOAD_IMAGE_AS_ICON = process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON === 'true'

View File

@ -21,4 +21,6 @@ export NEXT_PUBLIC_SENTRY_DSN=${SENTRY_DSN}
export NEXT_PUBLIC_SITE_ABOUT=${SITE_ABOUT}
export NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED}
export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS}
pm2 start ./pm2.json --no-daemon

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: 'Trennen',
jumpToNode: 'Zu diesem Knoten springen',
addParallelNode: 'Parallelen Knoten hinzufügen',
parallel: 'PARALLEL',
branch: 'ZWEIG',
},
env: {
envPanelTitle: 'Umgebungsvariablen',

View File

@ -281,6 +281,9 @@ const translation = {
notSelectModel: 'Please choose a model',
waitForImgUpload: 'Please wait for the image to upload',
},
warningMessage: {
timeoutExceeded: 'Results are not displayed due to timeout. Please refer to the logs to gather complete results.',
},
chatSubTitle: 'Instructions',
completionSubTitle: 'Prefix Prompt',
promptTip:

View File

@ -94,6 +94,8 @@ const translation = {
disconnect: 'Disconnect',
jumpToNode: 'Jump to this node',
addParallelNode: 'Add Parallel Node',
parallel: 'PARALLEL',
branch: 'BRANCH',
},
env: {
envPanelTitle: 'Environment Variables',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: 'Desconectar',
jumpToNode: 'Saltar a este nodo',
addParallelNode: 'Agregar nodo paralelo',
parallel: 'PARALELO',
branch: 'RAMA',
},
env: {
envPanelTitle: 'Variables de Entorno',

View File

@ -93,6 +93,8 @@ const translation = {
jumpToNode: 'پرش به این گره',
parallelRun: 'اجرای موازی',
addParallelNode: 'افزودن گره موازی',
parallel: 'موازی',
branch: 'شاخه',
},
env: {
envPanelTitle: 'متغیرهای محیطی',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: 'Déconnecter',
jumpToNode: 'Aller à ce nœud',
addParallelNode: 'Ajouter un nœud parallèle',
parallel: 'PARALLÈLE',
branch: 'BRANCHE',
},
env: {
envPanelTitle: 'Variables d\'Environnement',

View File

@ -96,6 +96,8 @@ const translation = {
parallelRun: 'समानांतर रन',
jumpToNode: 'इस नोड पर जाएं',
addParallelNode: 'समानांतर नोड जोड़ें',
parallel: 'समानांतर',
branch: 'शाखा',
},
env: {
envPanelTitle: 'पर्यावरण चर',

View File

@ -97,6 +97,8 @@ const translation = {
disconnect: 'Disconnettere',
jumpToNode: 'Vai a questo nodo',
addParallelNode: 'Aggiungi nodo parallelo',
parallel: 'PARALLELO',
branch: 'RAMO',
},
env: {
envPanelTitle: 'Variabili d\'Ambiente',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: '切る',
jumpToNode: 'このノードにジャンプします',
addParallelNode: '並列ノードを追加',
parallel: '並列',
branch: 'ブランチ',
},
env: {
envPanelTitle: '環境変数',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: '분리하다',
jumpToNode: '이 노드로 이동',
addParallelNode: '병렬 노드 추가',
parallel: '병렬',
branch: '브랜치',
},
env: {
envPanelTitle: '환경 변수',

View File

@ -93,6 +93,8 @@ const translation = {
jumpToNode: 'Przejdź do tego węzła',
disconnect: 'Odłączyć',
addParallelNode: 'Dodaj węzeł równoległy',
parallel: 'RÓWNOLEGŁY',
branch: 'GAŁĄŹ',
},
env: {
envPanelTitle: 'Zmienne Środowiskowe',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: 'Desligar',
jumpToNode: 'Ir para este nó',
addParallelNode: 'Adicionar nó paralelo',
parallel: 'PARALELO',
branch: 'RAMIFICAÇÃO',
},
env: {
envPanelTitle: 'Variáveis de Ambiente',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: 'Deconecta',
jumpToNode: 'Sari la acest nod',
addParallelNode: 'Adăugare nod paralel',
parallel: 'PARALEL',
branch: 'RAMURĂ',
},
env: {
envPanelTitle: 'Variabile de Mediu',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: 'Разъединять',
jumpToNode: 'Перейти к этому узлу',
addParallelNode: 'Добавить параллельный узел',
parallel: 'ПАРАЛЛЕЛЬНЫЙ',
branch: 'ВЕТКА',
},
env: {
envPanelTitle: 'Переменные среды',

View File

@ -93,6 +93,8 @@ const translation = {
addParallelNode: 'Paralel Düğüm Ekle',
disconnect: 'Ayırmak',
parallelRun: 'Paralel Koşu',
parallel: 'PARALEL',
branch: 'DAL',
},
env: {
envPanelTitle: 'Çevre Değişkenleri',

View File

@ -93,6 +93,8 @@ const translation = {
parallelRun: 'Паралельний біг',
jumpToNode: 'Перейти до цього вузла',
addParallelNode: 'Додати паралельний вузол',
parallel: 'ПАРАЛЕЛЬНИЙ',
branch: 'ГІЛКА',
},
env: {
envPanelTitle: 'Змінні середовища',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: 'Ngắt kết nối',
jumpToNode: 'Chuyển đến nút này',
addParallelNode: 'Thêm nút song song',
parallel: 'SONG SONG',
branch: 'NHÁNH',
},
env: {
envPanelTitle: 'Biến Môi Trường',

View File

@ -94,6 +94,8 @@ const translation = {
disconnect: '断开连接',
jumpToNode: '跳转到节点',
addParallelNode: '添加并行节点',
parallel: '并行',
branch: '分支',
},
env: {
envPanelTitle: '环境变量',

View File

@ -93,6 +93,8 @@ const translation = {
disconnect: '斷開',
jumpToNode: '跳轉到此節點',
addParallelNode: '添加並行節點',
parallel: '並行',
branch: '分支',
},
env: {
envPanelTitle: '環境變數',

View File

@ -1,6 +1,6 @@
{
"name": "dify-web",
"version": "0.8.0",
"version": "0.8.2",
"private": true,
"engines": {
"node": ">=18.17.0"
@ -44,6 +44,7 @@
"classnames": "^2.3.2",
"copy-to-clipboard": "^3.3.3",
"crypto-js": "^4.2.0",
"@svgdotjs/svg.js": "^3.2.4",
"dayjs": "^1.11.7",
"echarts": "^5.4.1",
"echarts-for-react": "^3.0.2",

View File

@ -1489,6 +1489,11 @@
dependencies:
"@sinonjs/commons" "^3.0.0"
"@svgdotjs/svg.js@^3.2.4":
version "3.2.4"
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz#4716be92a64c66b29921b63f7235fcfb953fb13a"
integrity sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==
"@swc/counter@^0.1.3":
version "0.1.3"
resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz"