diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index c90f9208..a5d02b2b 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -8,6 +8,7 @@ import { type FC, useRef, useEffect } from 'react' import clsx from 'clsx' import Markdown from '@/components/Markdown' import type { ChatContentProps } from './types' +import { Spin } from 'antd' /** * 聊天内容显示组件 @@ -21,7 +22,8 @@ const ChatContent: FC = ({ empty, labelPosition = 'bottom', labelFormat, - errorDesc + errorDesc, + renderRuntime }) => { // 滚动容器引用,用于控制自动滚动到底部 const scrollContainerRef = useRef<(HTMLDivElement | null)>(null) @@ -45,8 +47,8 @@ const ChatContent: FC = ({ 'rb:left-0 rb:text-left': item.role === 'assistant', // 助手消息左对齐 })}> {/* 流式加载时且内容为空则不显示 */} - {streamLoading && item.content === '' - ? null + {streamLoading && item.content === '' && !renderRuntime + ? : <> {/* 顶部标签(如时间戳、用户名等) */} {labelPosition === 'top' && @@ -55,16 +57,17 @@ const ChatContent: FC = ({ } {/* 消息气泡框 */} -
+ {item.subContent && renderRuntime && renderRuntime(item, index)} {/* 使用Markdown组件渲染消息内容 */} - +
{/* 底部标签(如时间戳、用户名等) */} {labelPosition === 'bottom' && diff --git a/web/src/components/Chat/types.ts b/web/src/components/Chat/types.ts index 851a8ccc..264ce39c 100644 --- a/web/src/components/Chat/types.ts +++ b/web/src/components/Chat/types.ts @@ -19,7 +19,9 @@ export interface ChatItem { /** 消息内容 */ content?: string | null; /** 创建时间 */ - created_at?: number | string + created_at?: number | string; + status?: string; + subContent?: Record[] } /** @@ -81,4 +83,5 @@ export interface ChatContentProps { /** 标签格式化函数 */ labelFormat: (item: ChatItem) => any; errorDesc?: string; + renderRuntime?: (item: ChatItem, index: number) => ReactNode; } \ No newline at end of file diff --git a/web/src/components/Markdown/CodeBlock.tsx b/web/src/components/Markdown/CodeBlock.tsx index 23d54c34..a125a997 100644 --- a/web/src/components/Markdown/CodeBlock.tsx +++ b/web/src/components/Markdown/CodeBlock.tsx @@ -6,6 +6,9 @@ import CopyBtn from './CopyBtn'; type ICodeBlockProps = { value: string; + needCopy?: boolean; + size?: 'small' | 'default'; + showLineNumbers?: boolean; } // enum languageType { @@ -16,6 +19,9 @@ type ICodeBlockProps = { const CodeBlock: FC = ({ value, + needCopy = true, + size = 'default', + showLineNumbers = false }) => { return ( @@ -23,24 +29,26 @@ const CodeBlock: FC = ({ {value} - + />} ) } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1df2eb6d..87a95c40 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1982,6 +1982,10 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re arrange: 'Arrange', redo: 'Redo', undo: 'Undo', + + input: 'Input', + output: 'Output', + error: 'Error Message', }, emotionEngine: { emotionEngineConfig: 'Emotion Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 39908757..fc683a66 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2076,6 +2076,10 @@ export const zh = { arrange: '整理', redo: '重做', undo: '撤销', + + input: '输入', + output: '输出', + error: '错误信息', }, emotionEngine: { emotionEngineConfig: '情感引擎配置', diff --git a/web/src/utils/stream.ts b/web/src/utils/stream.ts index e4179e25..2501fde5 100644 --- a/web/src/utils/stream.ts +++ b/web/src/utils/stream.ts @@ -123,6 +123,20 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe let response = await makeSSERequest(url, data, token || '', config); switch (response.status) { + case 500: + case 502: + const errorData = await response.json(); + errorData.error || i18n.t('common.serviceUpgrading'); + message.warning(errorData.error || i18n.t('common.serviceUpgrading')); + break + case 400: + const error = await response.json(); + message.warning(errorData.error); + throw error || 'Bad Request'; + case 504: + const errorJson = await response.json(); + message.warning(errorJson.error || i18n.t('common.serverError')); + break case 401: if (url?.includes('/public')) { return message.warning(i18n.t('common.publicApiCannotRefreshToken')); diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 246c2e4c..4a1ac5a7 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -1,8 +1,9 @@ import { forwardRef, useImperativeHandle, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import clsx from 'clsx' -import { Input, Form, App } from 'antd' -import { Space, Button } from 'antd' +import { Input, Form, App, Space, Button, Collapse } from 'antd' +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons' +import CodeBlock from '@/components/Markdown/CodeBlock' import ChatIcon from '@/assets/images/application/chat.png' import RbDrawer from '@/components/RbDrawer'; @@ -13,8 +14,11 @@ import ChatContent from '@/components/Chat/ChatContent' import type { ChatItem } from '@/components/Chat/types' import ChatSendIcon from '@/assets/images/application/chatSend.svg' import dayjs from 'dayjs' -import type { ChatRef, VariableConfigModalRef, StartVariableItem, GraphRef } from '../../types' +import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types' import { type SSEMessage } from '@/utils/stream' +import type { Variable } from '../Properties/VariableList/types' +import styles from './chat.module.css' +import Markdown from '@/components/Markdown' const Chat = forwardRef(({ appId, graphRef }, ref) => { const { t } = useTranslation() @@ -24,7 +28,7 @@ const Chat = forwardRef(({ appId const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) const [chatList, setChatList] = useState([]) - const [variables, setVariables] = useState([]) + const [variables, setVariables] = useState([]) const [streamLoading, setStreamLoading] = useState(false) const [conversationId, setConversationId] = useState(null) @@ -39,7 +43,7 @@ const Chat = forwardRef(({ appId if (startNodes.length) { const curVariables = startNodes[0].config.variables?.defaultValue - curVariables.forEach((vo: StartVariableItem) => { + curVariables.forEach((vo: Variable) => { if (typeof vo.default !== 'undefined') { vo.value = vo.default } @@ -60,7 +64,7 @@ const Chat = forwardRef(({ appId const handleEditVariables = () => { variableConfigModalRef.current?.handleOpen(variables) } - const handleSave = (values: StartVariableItem[]) => { + const handleSave = (values: Variable[]) => { setVariables([...values]) } const handleSend = () => { @@ -97,13 +101,28 @@ const Chat = forwardRef(({ appId role: 'assistant', content: '', created_at: Date.now(), + subContent: [], }]) const handleStreamMessage = (data: SSEMessage[]) => { - setStreamLoading(false) - data.forEach(item => { - const { chunk, conversation_id } = item.data as { chunk: string; conversation_id: string | null; }; + const { chunk, conversation_id, node_id, input, output, error, elapsed_time, status } = item.data as { + chunk: string; + conversation_id: string | null; + node_id: string; + node_name?: string; + input?: any; + output?: any; + elapsed_time?: string; + error?: any; + state: Record; + status?: 'completed' | 'failed' + }; + + const node = graphRef.current?.getNodes().find(n => n.id === node_id); + const { name, icon } = node?.getData() || {} + + console.log('node', node?.getData()) switch(item.event) { case 'message': @@ -119,6 +138,66 @@ const Chat = forwardRef(({ appId return newList }) break + case 'node_start': + setChatList(prev => { + const newList = [...prev] + const lastIndex = newList.length - 1 + if (lastIndex >= 0) { + const newSubContent = newList[lastIndex].subContent || [] + const filterIndex = newSubContent.findIndex(vo => vo.id === node_id) + if (filterIndex > -1) { + newSubContent[filterIndex] = { + ...newSubContent[filterIndex], + node_id: node_id, + node_name: name, + icon, + content: {}, + } + } else { + newSubContent.push({ + id: node_id, + node_id: node_id, + node_name: name, + icon, + content: {}, + }) + } + newList[lastIndex] = { + ...newList[lastIndex], + subContent: newSubContent + } + } + return newList + }) + break + case 'node_end': + case 'node_error': + setChatList(prev => { + const newList = [...prev] + const lastIndex = newList.length - 1 + if (lastIndex >= 0) { + const newSubContent = newList[lastIndex].subContent || [] + const filterIndex = newSubContent.findIndex(vo => vo.node_id === node_id) + if (filterIndex > -1 && newSubContent[filterIndex].content) { + newSubContent[filterIndex] = { + ...newSubContent[filterIndex], + content: { + input, + output, + error, + }, + status: status || 'completed', + elapsed_time + } + } + newList[lastIndex] = { + ...newList[lastIndex], + subContent: newSubContent + } + } + return newList + }) + break case 'workflow_end': setChatList(prev => { const newList = [...prev] @@ -126,6 +205,7 @@ const Chat = forwardRef(({ appId if (lastIndex >= 0) { newList[lastIndex] = { ...newList[lastIndex], + status, content: newList[lastIndex].content === '' ? null : newList[lastIndex].content } } @@ -142,14 +222,31 @@ const Chat = forwardRef(({ appId } form.setFieldValue('message', undefined) + setStreamLoading(true) draftRun(appId, { message: message, variables: params, stream: true, conversation_id: conversationId }, handleStreamMessage) + .catch((error) => { + setChatList(prev => { + const newList = [...prev] + const lastIndex = newList.length - 1 + if (lastIndex >= 0) { + newList[lastIndex] = { + ...newList[lastIndex], + status: 'failed', + content: null, + subContent: error.error + } + } + return newList + }) + }) .finally(() => { setLoading(false) + setStreamLoading(false) }) } // 暴露给父组件的方法 @@ -158,6 +255,11 @@ const Chat = forwardRef(({ appId handleClose })); + const getStatus = (status?: string) => { + return status === 'completed' ? 'rb:text-[#369F21]' : status === 'failed' ? 'rb:text-[#FF5D34]' : 'rb:text-[#5B6167]' + } + + console.log('chatList', chatList) return ( @@ -173,10 +275,7 @@ const Chat = forwardRef(({ appId onClose={handleClose} > } data={chatList} @@ -184,6 +283,87 @@ const Chat = forwardRef(({ appId labelPosition="bottom" labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} errorDesc={t('application.ReplyException')} + renderRuntime={(item, index) => { + return ( +
+ + {item.status === 'completed' ? : item.status === 'failed' ? : } + {t('application.workflow')} +
, + className: styles.collapseItem, + children: ( + Array.isArray(item.subContent) + ? + {item.subContent?.map(vo => ( + +
+ {vo.icon && } +
{vo.node_name || vo.node_id}
+
+ + {typeof vo.elapsed_time == 'number' && <>{vo.elapsed_time?.toFixed(3)}ms} + {vo.status === 'completed' ? : vo.status === 'failed' ? : } + + , + className: styles.collapseItem, + children: ( + + {vo.status === 'failed' && +
+
+ {t(`workflow.error`)} + +
+
+ +
+
+ } + {['input', 'output'].map(key => ( +
+
+ {t(`workflow.${key}`)} + +
+
+ +
+
+ ))} +
+ ) + }]} + /> + ))} +
+ :
+ +
+ ) + }]} + /> + + ) + }} />
diff --git a/web/src/views/Workflow/components/Chat/chat.module.css b/web/src/views/Workflow/components/Chat/chat.module.css new file mode 100644 index 00000000..99fe11f7 --- /dev/null +++ b/web/src/views/Workflow/components/Chat/chat.module.css @@ -0,0 +1,45 @@ +.completed { + background-color: rgba(54, 159, 33, 0.06); + border-color: rgba(54, 159, 33, 0.25); + border-radius: 8px; +} +.failed { + background-color: rgba(255, 138, 76, 0.08); + border-color: rgba(255, 138, 76, 0.20); + border-radius: 8px; +} +.default { + background-color: rgba(91, 97, 103, 0.08); + border-color: rgba(91, 97, 103, 0.30); + border-radius: 8px; +} +.collapse-item { + font-size: 12px; + line-height: 16px; +} +.collapse-item:global(.ant-collapse-item>.ant-collapse-header) { + padding: 8px 12px; +} +.collapse-item:global(.ant-collapse-item>.ant-collapse-header .ant-collapse-expand-icon) { + height: 16px; +} +.completed:global(.ant-collapse .ant-collapse-content), +.failed:global(.ant-collapse .ant-collapse-content) { + background-color: transparent; + border-top: none; +} +:global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) { + padding-top: 0; +} +.collapse-item :global(.ant-collapse) { + /* background-color: #F0F3F8; */ + background-color: #FBFDFF; + border-radius: 6px; +} +.collapse-item :global(.ant-collapse>.ant-collapse-item:last-child), +.collapse-item :global(.ant-collapse>.ant-collapse-item:last-child>.ant-collapse-header) { + border-radius: 0 0 6px 6px; +} +.collapse-item :global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) { + padding: 0 4px 4px 4px; +} \ No newline at end of file