feat: llm text stream support for workflow app (#3798)

Co-authored-by: JzoNg <jzongcode@gmail.com>
This commit is contained in:
takatost
2024-04-28 17:37:00 +08:00
committed by GitHub
parent 8e4989ed03
commit ff67a6d338
27 changed files with 549 additions and 58 deletions

View File

@@ -8,9 +8,8 @@ import { useParams } from 'next/navigation'
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
import { useBoolean } from 'ahooks'
import { HashtagIcon } from '@heroicons/react/24/solid'
// import PromptLog from '@/app/components/app/chat/log'
import ResultTab from './result-tab'
import { Markdown } from '@/app/components/base/markdown'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import AudioBtn from '@/app/components/base/audio-btn'
@@ -26,7 +25,6 @@ import EditReplyModal from '@/app/components/app/annotation/edit-annotation-moda
import { useStore as useAppStore } from '@/app/components/app/store'
import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
const MAX_DEPTH = 3
@@ -293,23 +291,17 @@ const GenerationItem: FC<IGenerationItemProps> = ({
<div className={`flex ${contentClassName}`}>
<div className='grow w-0'>
{workflowProcessData && (
<WorkflowProcessItem grayBg data={workflowProcessData} expand={workflowProcessData.expand} />
<WorkflowProcessItem grayBg hideInfo data={workflowProcessData} expand={workflowProcessData.expand} />
)}
{workflowProcessData && !isError && (
<ResultTab data={workflowProcessData} content={content} />
)}
{isError && (
<div className='text-gray-400 text-sm'>{t('share.generation.batchFailed.outputPlaceholder')}</div>
)}
{!isError && (typeof content === 'string') && (
{!workflowProcessData && !isError && (typeof content === 'string') && (
<Markdown content={content} />
)}
{!isError && (typeof content !== 'string') && (
<CodeEditor
readOnly
title={<div/>}
language={CodeLanguage.json}
value={content}
isJSONStringifyBeauty
/>
)}
</div>
</div>
@@ -427,7 +419,11 @@ const GenerationItem: FC<IGenerationItemProps> = ({
</>
)}
</div>
<div className='text-xs text-gray-500'>{content?.length} {t('common.unit.char')}</div>
<div>
{!workflowProcessData && (
<div className='text-xs text-gray-500'>{content?.length} {t('common.unit.char')}</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
import {
memo,
useEffect,
// useRef,
useState,
} from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
// import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
// import { WorkflowRunningStatus } from '@/app/components/workflow/types'
const ResultTab = ({
data,
content,
}: {
data?: WorkflowProcess
content: any
}) => {
const { t } = useTranslation()
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
const switchTab = async (tab: string) => {
setCurrentTab(tab)
}
useEffect(() => {
if (data?.resultText)
switchTab('RESULT')
else
switchTab('DETAIL')
}, [data?.resultText])
return (
<div className='grow relative flex flex-col'>
{data?.resultText && (
<div className='shrink-0 flex items-center mb-2 border-b-[0.5px] border-[rgba(0,0,0,0.05)]'>
<div
className={cn(
'mr-6 py-3 border-b-2 border-transparent text-[13px] font-semibold leading-[18px] text-gray-400 cursor-pointer',
currentTab === 'RESULT' && '!border-[rgb(21,94,239)] text-gray-700',
)}
onClick={() => switchTab('RESULT')}
>{t('runLog.result')}</div>
<div
className={cn(
'mr-6 py-3 border-b-2 border-transparent text-[13px] font-semibold leading-[18px] text-gray-400 cursor-pointer',
currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-gray-700',
)}
onClick={() => switchTab('DETAIL')}
>{t('runLog.detail')}</div>
</div>
)}
<div className={cn('grow bg-white')}>
{currentTab === 'RESULT' && (
<Markdown content={data?.resultText || ''} />
)}
{currentTab === 'DETAIL' && content && (
<CodeEditor
readOnly
title={<div>JSON OUTPUT</div>}
language={CodeLanguage.json}
value={content}
isJSONStringifyBeauty
/>
)}
</div>
</div>
)
}
export default memo(ResultTab)

View File

@@ -54,6 +54,7 @@ export type WorkflowProcess = {
status: WorkflowRunningStatus
tracing: NodeTracing[]
expand?: boolean // for UI
resultText?: string
}
export type ChatItem = IChatItem & {

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="image-indent-left">
<path id="Icon" d="M21 9.25H15M21 4H3M21 14.75H15M21 20H3M4.6 16H9.4C9.96005 16 10.2401 16 10.454 15.891C10.6422 15.7951 10.7951 15.6422 10.891 15.454C11 15.2401 11 14.9601 11 14.4V9.6C11 9.03995 11 8.75992 10.891 8.54601C10.7951 8.35785 10.6422 8.20487 10.454 8.10899C10.2401 8 9.96005 8 9.4 8H4.6C4.03995 8 3.75992 8 3.54601 8.10899C3.35785 8.20487 3.20487 8.35785 3.10899 8.54601C3 8.75992 3 9.03995 3 9.6V14.4C3 14.9601 3 15.2401 3.10899 15.454C3.20487 15.6422 3.35785 15.7951 3.54601 15.891C3.75992 16 4.03995 16 4.6 16Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 743 B

View File

@@ -0,0 +1,39 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "24",
"height": "24",
"viewBox": "0 0 24 24",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "image-indent-left"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Icon",
"d": "M21 9.25H15M21 4H3M21 14.75H15M21 20H3M4.6 16H9.4C9.96005 16 10.2401 16 10.454 15.891C10.6422 15.7951 10.7951 15.6422 10.891 15.454C11 15.2401 11 14.9601 11 14.4V9.6C11 9.03995 11 8.75992 10.891 8.54601C10.7951 8.35785 10.6422 8.20487 10.454 8.10899C10.2401 8 9.96005 8 9.4 8H4.6C4.03995 8 3.75992 8 3.54601 8.10899C3.35785 8.20487 3.20487 8.35785 3.10899 8.54601C3 8.75992 3 9.03995 3 9.6V14.4C3 14.9601 3 15.2401 3.10899 15.454C3.20487 15.6422 3.35785 15.7951 3.54601 15.891C3.75992 16 4.03995 16 4.6 16Z",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
}
]
},
"name": "ImageIndentLeft"
}

View File

@@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './ImageIndentLeft.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'ImageIndentLeft'
export default Icon

View File

@@ -1,6 +1,7 @@
export { default as AlignLeft } from './AlignLeft'
export { default as BezierCurve03 } from './BezierCurve03'
export { default as Colors } from './Colors'
export { default as ImageIndentLeft } from './ImageIndentLeft'
export { default as LeftIndent02 } from './LeftIndent02'
export { default as LetterSpacing01 } from './LetterSpacing01'
export { default as TypeSquare } from './TypeSquare'

View File

@@ -201,6 +201,7 @@ const Result: FC<IResultProps> = ({
status: WorkflowRunningStatus.Running,
tracing: [],
expand: false,
resultText: '',
})
setRespondingFalse()
},
@@ -243,15 +244,25 @@ const Result: FC<IResultProps> = ({
}))
if (!data.outputs)
setCompletionRes('')
else if (Object.keys(data.outputs).length > 1)
setCompletionRes(data.outputs)
else
setCompletionRes(data.outputs[Object.keys(data.outputs)[0]])
setCompletionRes(data.outputs)
setRespondingFalse()
setMessageId(tempMessageId)
onCompleted(getCompletionRes(), taskId, true)
isEnd = true
},
onTextChunk: (params) => {
const { data: { text } } = params
setWorkflowProccessData(produce(getWorkflowProccessData()!, (draft) => {
draft.resultText += text
}))
},
onTextReplace: (params) => {
const { data: { text } } = params
setWorkflowProccessData(produce(getWorkflowProccessData()!, (draft) => {
draft.resultText = text
}))
},
},
isInstalledApp,
installedAppInfo?.id,

View File

@@ -124,6 +124,7 @@ export const useWorkflowRun = () => {
status: WorkflowRunningStatus.Running,
},
tracing: [],
resultText: '',
})
ssePost(
@@ -284,6 +285,27 @@ export const useWorkflowRun = () => {
if (onNodeFinished)
onNodeFinished(params)
},
onTextChunk: (params) => {
const { data: { text } } = params
const {
workflowRunningData,
setWorkflowRunningData,
} = workflowStore.getState()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
draft.resultTabActive = true
draft.resultText += text
}))
},
onTextReplace: (params) => {
const { data: { text } } = params
const {
workflowRunningData,
setWorkflowRunningData,
} = workflowStore.getState()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
draft.resultText = text
}))
},
...restCallback,
},
)

View File

@@ -1,12 +1,12 @@
import {
memo,
useEffect,
useRef,
// useRef,
useState,
} from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import OutputPanel from '../run/output-panel'
import ResultText from '../run/result-text'
import ResultPanel from '../run/result-panel'
import TracingPanel from '../run/tracing-panel'
import {
@@ -32,22 +32,15 @@ const WorkflowPreview = () => {
setCurrentTab(tab)
}
const [height, setHieght] = useState(0)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (showDebugAndPreviewPanel && showInputsPanel)
setCurrentTab('INPUT')
}, [showDebugAndPreviewPanel, showInputsPanel])
const adjustResultHeight = () => {
if (ref.current)
setHieght(ref.current?.clientHeight - 16 - 16 - 2 - 1)
}
useEffect(() => {
adjustResultHeight()
}, [])
if ((workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData?.result.status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText)
switchTab('DETAIL')
}, [workflowRunningData])
return (
<div className={`
@@ -107,7 +100,7 @@ const WorkflowPreview = () => {
}}
>{t('runLog.tracing')}</div>
</div>
<div ref={ref} className={cn(
<div className={cn(
'grow bg-white h-0 overflow-y-auto rounded-b-2xl',
(currentTab === 'RESULT' || currentTab === 'TRACING') && '!bg-gray-50',
)}>
@@ -115,11 +108,11 @@ const WorkflowPreview = () => {
<InputsPanel onRun={() => switchTab('RESULT')} />
)}
{currentTab === 'RESULT' && (
<OutputPanel
<ResultText
isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result}
outputs={workflowRunningData?.result?.outputs}
outputs={workflowRunningData?.resultText}
error={workflowRunningData?.result?.error}
height={height}
onClick={() => switchTab('DETAIL')}
/>
)}
{currentTab === 'DETAIL' && (

View File

@@ -0,0 +1,56 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { ImageIndentLeft } from '@/app/components/base/icons/src/vender/line/editor'
import { Markdown } from '@/app/components/base/markdown'
import LoadingAnim from '@/app/components/app/chat/loading-anim'
type ResultTextProps = {
isRunning?: boolean
outputs?: any
error?: string
onClick?: () => void
}
const ResultText: FC<ResultTextProps> = ({
isRunning,
outputs,
error,
onClick,
}) => {
const { t } = useTranslation()
return (
<div className='bg-gray-50 py-2'>
{isRunning && !outputs && (
<div className='pt-4 pl-[26px]'>
<LoadingAnim type='text' />
</div>
)}
{!isRunning && error && (
<div className='px-4'>
<div className='px-3 py-[10px] rounded-lg !bg-[#fef3f2] border-[0.5px] border-[rbga(0,0,0,0.05)] shadow-xs'>
<div className='text-xs leading-[18px] text-[#d92d20]'>{error}</div>
</div>
</div>
)}
{!isRunning && !outputs && !error && (
<div className='mt-[120px] px-4 py-2 flex flex-col items-center text-[13px] leading-[18px] text-gray-500'>
<ImageIndentLeft className='w-6 h-6 text-gray-400' />
<div className='mr-2'>{t('runLog.resultEmpty.title')}</div>
<div>
{t('runLog.resultEmpty.tipLeft')}
<span onClick={onClick} className='cursor-pointer text-primary-600'>{t('runLog.resultEmpty.link')}</span>
{t('runLog.resultEmpty.tipRight')}
</div>
</div>
)}
{outputs && (
<div className='px-4 py-2'>
<Markdown content={outputs} />
</div>
)}
</div>
)
}
export default ResultText

View File

@@ -19,11 +19,16 @@ import type {
} from './types'
import { WorkflowContext } from './context'
type PreviewRunningData = WorkflowRunningData & {
resultTabActive?: boolean
resultText?: string
}
type Shape = {
appId: string
panelWidth: number
workflowRunningData?: WorkflowRunningData
setWorkflowRunningData: (workflowData?: WorkflowRunningData) => void
workflowRunningData?: PreviewRunningData
setWorkflowRunningData: (workflowData: PreviewRunningData) => void
historyWorkflowData?: HistoryWorkflowData
setHistoryWorkflowData: (historyWorkflowData?: HistoryWorkflowData) => void
showRunHistory: boolean

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: 'Gesamtzeichen',
steps: 'Ausführungsschritte',
},
resultEmpty: {
title: 'Dieser Lauf gibt nur das JSON-Format aus',
tipLeft: 'Bitte gehen Sie zum ',
Link: 'Detailpanel',
tipRight: 'ansehen.',
},
}
export default translation

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: 'Total Tokens',
steps: 'Run Steps',
},
resultEmpty: {
title: 'This run only output JSON format,',
tipLeft: 'please go to the ',
link: 'detail panel',
tipRight: ' view it.',
},
}
export default translation

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: 'Total des jetons',
steps: 'Étapes d\'exécution',
},
resultEmpty: {
title: 'Cela exécute uniquement le format de sortie JSON,',
tipLeft: 'veuillez aller à ',
link: 'panneau de détail',
tipRight: ' visualisez-le.',
},
}
export default translation

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: 'トークンの合計',
steps: '実行ステップ',
},
resultEmpty: {
title: 'この実行では JSON 形式のみが出力されます',
tipLeft: 'にアクセスしてください',
link: '詳細パネル',
tipRight: '表示します。',
},
}
export default translation

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: 'Total de Tokens',
steps: 'Passos de Execução',
},
resultEmpty: {
title: 'Esta execução apenas produz o formato JSON,',
tipLeft: 'por favor vá para ',
link: 'painel de detalhes',
tipRight: ' veja.',
},
}
export default translation

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: 'Загальна кількість токенів',
steps: 'Кроки виконання',
},
resultEmpty: {
title: 'Цей запуск лише вихідного формату JSON,',
tipLeft: 'будь ласка, перейдіть до ',
link: 'панель деталей',
tipRight: ' переглянути.',
},
}
export default translation

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: 'Tổng số token',
steps: 'Các bước chạy',
},
resultEmpty: {
title: 'Chạy này chỉ xuất ra định dạng JSON,',
tipLeft: 'vui lòng truy cập ',
link: 'bảng chi tiết',
tipRight: ' xem nó.',
},
}
export default translation

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: '总 token 数',
steps: '运行步数',
},
resultEmpty: {
title: '本次运行仅输出JSON格式',
tipLeft: '请转到',
link: '详细信息面板',
tipRight: '查看它。',
},
}
export default translation

View File

@@ -18,6 +18,12 @@ const translation = {
tokens: '總 token 數',
steps: '執行步數',
},
resultEmpty: {
title: '本運行僅輸出JSON格式',
tipLeft: '請到',
link: '詳細資訊面板',
tipRight: '查看它。',
},
}
export default translation

View File

@@ -1,4 +1,4 @@
import type { IOnCompleted, IOnData, IOnError, IOnFile, IOnMessageEnd, IOnMessageReplace, IOnNodeFinished, IOnNodeStarted, IOnThought, IOnWorkflowFinished, IOnWorkflowStarted } from './base'
import type { IOnCompleted, IOnData, IOnError, IOnFile, IOnMessageEnd, IOnMessageReplace, IOnNodeFinished, IOnNodeStarted, IOnTextChunk, IOnTextReplace, IOnThought, IOnWorkflowFinished, IOnWorkflowStarted } from './base'
import {
del as consoleDel, get as consoleGet, patch as consolePatch, post as consolePost,
delPublic as del, getPublic as get, patchPublic as patch, postPublic as post, ssePost,
@@ -72,11 +72,15 @@ export const sendWorkflowMessage = async (
onNodeStarted,
onNodeFinished,
onWorkflowFinished,
onTextChunk,
onTextReplace,
}: {
onWorkflowStarted: IOnWorkflowStarted
onNodeStarted: IOnNodeStarted
onNodeFinished: IOnNodeFinished
onWorkflowFinished: IOnWorkflowFinished
onTextChunk: IOnTextChunk
onTextReplace: IOnTextReplace
},
isInstalledApp: boolean,
installedAppId = '',
@@ -86,7 +90,7 @@ export const sendWorkflowMessage = async (
...body,
response_mode: 'streaming',
},
}, { onNodeStarted, onWorkflowStarted, onWorkflowFinished, isPublicAPI: !isInstalledApp, onNodeFinished })
}, { onNodeStarted, onWorkflowStarted, onWorkflowFinished, isPublicAPI: !isInstalledApp, onNodeFinished, onTextChunk, onTextReplace })
}
export const fetchAppInfo = async () => {