diff --git a/web/src/api/application.ts b/web/src/api/application.ts index 583ff8b9..72521d92 100644 --- a/web/src/api/application.ts +++ b/web/src/api/application.ts @@ -1,7 +1,8 @@ import { request } from '@/utils/request' import type { Application } from '@/views/ApplicationManagement/types' import type { Config } from '@/views/ApplicationConfig/types' -import { handleSSE } from '@/utils/stream' +import { handleSSE, type SSEMessage } from '@/utils/stream' +import type { QueryParams } from '@/views/Conversation/types' // 应用列表 export const getApplicationListUrl = '/apps' @@ -37,10 +38,10 @@ export const saveMultiAgentConfig = (app_id: string, values: Config) => { return request.put(`/apps/${app_id}/multi-agent`, values) } // 模型比对试运行 -export const runCompare = (app_id: string, values: Record, onMessage?: (data: string) => void) => { +export const runCompare = (app_id: string, values: Record, onMessage?: (data: SSEMessage[]) => void) => { return handleSSE(`/apps/${app_id}/draft/run/compare`, values, onMessage) } -export const draftRun = (app_id: string, values: Record, onMessage?: (data: string) => void) => { +export const draftRun = (app_id: string, values: Record, onMessage?: (data: SSEMessage[]) => void) => { return handleSSE(`/apps/${app_id}/draft/run`, values, onMessage) } // 删除应用 @@ -76,18 +77,7 @@ export const getConversationHistory = (share_token: string, data: { page: number }) } // 发送体验对话 -export const sendConversation = (share_token: string, values: { - message: string; - web_search: boolean; - memory: boolean; - stream: boolean; - conversation_id: string | null; -}, onMessage, shareToken: string) => { - // return request.post(`/public/share/chat`, values, { - // headers: { - // 'Authorization': `Bearer ${localStorage.getItem(`shareToken_${share_token}`)}` - // } - // }) +export const sendConversation = (values: QueryParams, onMessage: (data: SSEMessage[]) => void, shareToken: string) => { return handleSSE(`/public/share/chat`, values, onMessage, { headers: { 'Authorization': `Bearer ${shareToken}` diff --git a/web/src/utils/stream.ts b/web/src/utils/stream.ts index 2c827c2f..3ef1db39 100644 --- a/web/src/utils/stream.ts +++ b/web/src/utils/stream.ts @@ -3,7 +3,47 @@ import i18n from '@/i18n' import { cookieUtils } from './request' const API_PREFIX = '/api' -export const handleSSE = async (url: string, data: any, onMessage?: (data: string) => void, config = {}) => { +export interface SSEMessage { + event?: string + data?: string | object +} +export function parseSSEToJSON(sseString: string) { + const events: SSEMessage[] = [] + const lines = sseString.trim().split('\n') + + let currentEvent: SSEMessage = {} + + try { + for (const line of lines) { + if (line.startsWith('event:')) { + if (Object.keys(currentEvent).length > 0) { + events.push(currentEvent) + currentEvent = {} + } + currentEvent.event = line.substring(6).trim() + } else if (line.startsWith('data:')) { + const dataStr = line.substring(5).trim() + try { + currentEvent.data = JSON.parse(dataStr.replace(/"/g, '"')) + } catch { + currentEvent.data = dataStr + } + } + } + + if (Object.keys(currentEvent).length > 0) { + events.push(currentEvent) + } + + return events + } catch (error) { + console.error('Parse stream error:', error) + return [] + } +} + + +export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMessage[]) => void, config = { headers: {} }) => { try { const token = cookieUtils.get('authToken'); const response = await fetch(`${API_PREFIX}${url}`, { @@ -37,7 +77,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: strin const chunk = decoder.decode(value, { stream: true }); if (onMessage) { - onMessage(chunk); + onMessage(parseSSEToJSON(chunk) ?? {}); } } break; diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index dc600f1f..2e032d84 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -239,6 +239,7 @@ const Agent = forwardRef((_props, ref) => { return [ ...(prev || []).map(item => ({ ...item, + conversation_id: undefined, list: [] })), newChatItem diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index 9a70b5f2..02ce405c 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -1,46 +1,125 @@ -import { type FC, useRef, useEffect, useState } from 'react'; +import { type FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx' import { Input, Form } from 'antd' import ChatIcon from '@/assets/images/application/chat.png' import ChatSendIcon from '@/assets/images/application/chatSend.svg' import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png' -import type { ChatItem, ChatData, Config } from '../types' +import type { ChatData, Config } from '../types' import { runCompare, draftRun } from '@/api/application' import Empty from '@/components/Empty' -import Markdown from '@/components/Markdown' +import ChatContent from '@/components/Chat/ChatContent' +import type { ChatItem } from '@/components/Chat/types' +import { type SSEMessage } from '@/utils/stream' interface ChatProps { chatList: ChatData[]; data: Config; - updateChatList: (list: ChatData[]) => void; + updateChatList: React.Dispatch>; handleSave: (flag?: boolean) => Promise; - source?: 'cluster' | 'agent'; + source?: 'multi_agent' | 'agent'; } const Chat: FC = ({ chatList, data, updateChatList, handleSave, source = 'agent' }) => { const { t } = useTranslation(); const [form] = Form.useForm<{ message: string }>() - const scrollContainerRefs = useRef<(HTMLDivElement | null)[]>([]) const [loading, setLoading] = useState(false) - const [isCluster, setIsCluster] = useState(source === 'cluster') + const [isCluster, setIsCluster] = useState(source === 'multi_agent') const [conversationId, setConversationId] = useState(null) const [compareLoading, setCompareLoading] = useState(false) - - // 当聊天列表更新时,自动滚动到底部 + useEffect(() => { - // 延迟一下,确保DOM已经更新 - setTimeout(() => { - scrollContainerRefs.current.forEach(container => { - if (container) { - container.scrollTop = container.scrollHeight; - } - }); - }, 0); - }, [chatList]); - useEffect(() => { - setIsCluster(source === 'cluster') + setIsCluster(source === 'multi_agent') }, [source]) + const addUserMessage = (message: string) => { + const newUserMessage: ChatItem = { + role: 'user', + content: message, + created_at: Date.now(), + }; + updateChatList(prev => prev.map(item => ({ + ...item, + list: [...(item.list || []), newUserMessage] + }))) + } + const addAssistantMessage = () => { + const assistantMessage: ChatItem = { + role: 'assistant', + content: '', + created_at: Date.now(), + }; + + if (isCluster) { + updateChatList(prev => prev.map(item => ({ + ...item, + list: [...(item.list || []), assistantMessage] + }))) + } else { + const assistantMessages: Record = {} + chatList.forEach(item => { + assistantMessages[item.model_config_id as string] = assistantMessage + }) + updateChatList(prev => prev.map(item => ({ + ...item, + list: [...(item.list || []), assistantMessages[item.model_config_id as string]] + }))) + } + } + const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string) => { + if (!content || !model_config_id) return + updateChatList(prev => { + const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id); + if (targetIndex !== -1) { + const modelChatList = [...prev] + const curModelChat = modelChatList[targetIndex] + const curChatMsgList = curModelChat.list || [] + const lastMsg = curChatMsgList[curChatMsgList.length - 1] + if (lastMsg.role === 'assistant') { + modelChatList[targetIndex] = { + ...modelChatList[targetIndex], + conversation_id: conversation_id, + list: [ + ...curChatMsgList.slice(0, curChatMsgList.length - 1), + { + ...lastMsg, + content: lastMsg.content + content + } + ] + } + } + return [...modelChatList] + } + return prev; + }) + } + const updateErrorAssistantMessage = (message_length: number, model_config_id?: string) => { + if (message_length > 0 || !model_config_id) return + + updateChatList(prev => { + const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id); + if (targetIndex > -1) { + const modelChatList = [...prev] + const curModelChat = modelChatList[targetIndex] + const curChatMsgList = curModelChat.list || [] + const lastMsg = curChatMsgList[curChatMsgList.length - 1] + if (lastMsg.role === 'assistant') { + modelChatList[targetIndex] = { + ...modelChatList[targetIndex], + list: [ + ...curChatMsgList.slice(0, curChatMsgList.length - 1), + { + ...lastMsg, + content: null + } + ] + } + } + return [...modelChatList] + } + + return prev + }) + } const handleSend = () => { if (loading) return setLoading(true) @@ -48,182 +127,47 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc handleSave(false) .then(() => { const message = form.getFieldValue('message') - if (!message || message.trim() === '') return - const newUserMessage: ChatItem = { - role: 'question', - content: message, - time: Date.now(), - }; - updateChatList((prev: ChatData[]) => { - return prev.map(item => ({ - ...item, - list: [ - ...(item.list || []), - newUserMessage - ] - })) - }) + if (!message?.trim()) return + + addUserMessage(message) form.setFieldsValue({ message: undefined }) - // 添加空的助手消息用于流式更新 - const assistantMessages: Record = {}; - if (isCluster) { - const assistantMessage: ChatItem = { - role: 'answer', - content: '', - time: Date.now(), - }; - assistantMessages['cluster'] = assistantMessage; - updateChatList((prev: ChatData[]) => prev.map(item => ({ - ...item, - list: [...(item.list || []), assistantMessage] - }))) - } else { - chatList.forEach(item => { - const assistantMessage: ChatItem = { - role: 'answer', - content: '', - time: Date.now(), - }; - assistantMessages[item.model_config_id] = assistantMessage; - }); - updateChatList((prev: ChatData[]) => prev.map(item => ({ - ...item, - list: [...(item.list || []), assistantMessages[item.model_config_id]] - }))) - } + addAssistantMessage() - const handleStreamMessage = (data: string) => { + const handleStreamMessage = (data: SSEMessage[]) => { setCompareLoading(false) - try { - const lines = data.split('\n'); - let currentEvent = ''; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - if (line.startsWith('event:')) { - currentEvent = line.substring(6).trim(); - } else if (line.startsWith('data:') && (!isCluster && currentEvent === 'model_message')) { - const jsonData = line.substring(5).trim(); - const parsed = JSON.parse(jsonData); - - if (parsed.content && parsed.model_config_id) { - const targetIndex = chatList.findIndex(item => item.model_config_id === parsed.model_config_id); - if (targetIndex !== -1) { - updateChatList((prev: ChatData[]) => prev.map((item, index) => { - if (index === targetIndex) { - return { - ...item, - conversation_id: parsed.conversation_id, - list: item.list?.map((msg, msgIndex) => { - if (msgIndex === item.list!.length - 1 && msg.role === 'answer') { - return { ...msg, content: msg.content + parsed.content }; - } - return msg; - }) || [] - }; - } - return item; - })) - } - } - } else if (line.startsWith('data:') && (isCluster && currentEvent === 'message')) { - const jsonData = line.substring(5).trim(); - const parsed = JSON.parse(jsonData); - if (parsed.content) { - updateChatList((prev: ChatData[]) => prev.map((item, index) => { - if (index === 0) { - return { - ...item, - list: item.list?.map((msg, msgIndex) => { - if (msgIndex === item.list!.length - 1 && msg.role === 'answer') { - return { ...msg, content: (msg.content || '') + parsed.content }; - } - return msg; - }) || [] - }; - } - return item; - })) - } - if (parsed.conversation_id) { - setConversationId(parsed.conversation_id); - } - } else if (line.startsWith('data:') && (!isCluster && currentEvent === 'model_end')) { - const jsonData = line.substring(5).trim(); - const parsed = JSON.parse(jsonData); - - if (parsed.message_length === 0 && parsed.model_config_id) { - const targetIndex = chatList.findIndex(item => item.model_config_id === parsed.model_config_id); - if (targetIndex !== -1) { - updateChatList((prev: ChatData[]) => prev.map((item, index) => { - if (index === targetIndex) { - return { - ...item, - list: item.list?.map((msg, msgIndex) => { - if (msgIndex === item.list!.length - 1 && msg.role === 'answer') { - return { ...msg, content: null }; - } - return msg; - }) || [] - }; - } - return item; - })) - } - } - } else if (line.startsWith('data:') && (isCluster && currentEvent === 'model_end')) { - const jsonData = line.substring(5).trim(); - const parsed = JSON.parse(jsonData); - if (parsed.message_length === 0) { - updateChatList((prev: ChatData[]) => prev.map((item, index) => { - if (index === 0) { - return { - ...item, - list: item.list?.map((msg, msgIndex) => { - if (msgIndex === item.list!.length - 1 && msg.role === 'answer') { - return { ...msg, content: null }; - } - return msg; - }) || [] - }; - } - return item; - })) - } - if (parsed.conversation_id) { - setConversationId(parsed.conversation_id); - } - } else if (currentEvent === 'compare_end') { + data.map(item => { + const { model_config_id, conversation_id, content, message_length } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number }; + + switch(item.event) { + case 'model_message': + updateAssistantMessage(content, model_config_id, conversation_id) + break; + case 'model_end': + updateErrorAssistantMessage(message_length, model_config_id) + break; + case 'compare_end': setLoading(false); - } + break; } - } catch (e) { - console.error('Parse stream data error:', e); - } + }) }; setTimeout(() => { - if (isCluster) { - draftRun(data.app_id, { message, conversation_id: conversationId, stream: true }, handleStreamMessage) - .finally(() => setLoading(false)) - } else { - runCompare(data.app_id, { - message, - models: chatList.map(item => ({ - model_config_id: item.model_config_id, - label: item.label, - model_parameters: item.model_parameters, - conversation_id: item.conversation_id - })), - variables: {}, - "parallel": true, - "stream": true, - "timeout": 60, - }, handleStreamMessage) - .finally(() => setLoading(false)); - } + runCompare(data.app_id, { + message, + models: chatList.map(item => ({ + model_config_id: item.model_config_id, + label: item.label, + model_parameters: item.model_parameters, + conversation_id: item.conversation_id + })), + variables: {}, + "parallel": true, + "stream": true, + "timeout": 60, + }, handleStreamMessage) + .finally(() => setLoading(false)); }, 0) }) .catch(() => { @@ -231,6 +175,131 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc setCompareLoading(false) }) } + + const addClusterAssistantMessage = () => { + const assistantMessage: ChatItem = { + role: 'assistant', + content: '', + created_at: Date.now(), + }; + updateChatList(prev => prev.map(item => ({ + ...item, + list: [...(item.list || []), assistantMessage] + }))) + } + const updateClusterAssistantMessage = (content?: string) => { + if (!content) return + updateChatList(prev => { + const modelChatList = [...prev] + const curModelChat = modelChatList[0] + const curChatMsgList = curModelChat.list || [] + const lastMsg = curChatMsgList[curChatMsgList.length - 1] + if (lastMsg.role === 'assistant') { + modelChatList[0] = { + ...modelChatList[0], + list: [ + ...curChatMsgList.slice(0, curChatMsgList.length - 1), + { + ...lastMsg, + content: lastMsg.content + content + } + ] + } + } + return [...modelChatList] + }) + updateChatList((prev: ChatData[]) => prev.map((item, index) => { + if (index === 0) { + return { + ...item, + list: item.list?.map((msg, msgIndex) => { + if (msgIndex === item.list!.length - 1 && msg.role === 'assistant') { + return { ...msg, content: (msg.content || '') + content }; + } + return msg; + }) || [] + }; + } + return item; + })) + } + const updateClusterErrorAssistantMessage = (message_length: number) => { + if (message_length > 0) return + + updateChatList(prev => { + const modelChatList = [...prev] + const curModelChat = modelChatList[0] + const curChatMsgList = curModelChat.list || [] + const lastMsg = curChatMsgList[curChatMsgList.length - 1] + if (lastMsg.role === 'assistant') { + modelChatList[0] = { + ...modelChatList[0], + list: [ + ...curChatMsgList.slice(0, curChatMsgList.length - 1), + { + ...lastMsg, + content: null + } + ] + } + } + return [...modelChatList] + }) + } + const handleClusterSend = () => { + if (loading) return + setLoading(true) + setCompareLoading(true) + handleSave(false) + .then(() => { + const message = form.getFieldValue('message') + if (!message || message.trim() === '') return + addUserMessage(message) + form.setFieldsValue({ message: undefined }) + addClusterAssistantMessage() + + const handleStreamMessage = (data: SSEMessage[]) => { + setCompareLoading(false) + + data.map(item => { + const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number }; + + switch(item.event) { + case 'message': + updateClusterAssistantMessage(content) + if (conversation_id && conversationId !== conversation_id) { + setConversationId(conversation_id); + } + break; + case 'model_end': + updateClusterErrorAssistantMessage(message_length) + break; + case 'compare_end': + setLoading(false); + break; + } + }) + }; + + setTimeout(() => { + draftRun( + data.app_id, + { + message, + conversation_id: conversationId, + stream: true + }, + handleStreamMessage + ) + .finally(() => setLoading(false)) + }, 0) + }) + .catch(() => { + setLoading(false) + setCompareLoading(false) + }) + } + const handleDelete = (index: number) => { updateChatList(chatList.filter((_, voIndex) => voIndex !== index)) } @@ -258,69 +327,55 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc
{chat.label}
handleDelete(index)} >
} - {!chat.list || chat.list.length === 0 - ? - : ( -
scrollContainerRefs.current[index] = el} className={clsx(`rb:relative rb:overflow-y-auto rb:overflow-x-hidden`, { - 'rb:h-[calc(100vh-186px)]': isCluster, - 'rb:h-[calc(100vh-286px)]': !isCluster, - })}> - {chat.list?.map((vo, voIndex) => { - if (compareLoading && voIndex === chat.list?.length - 1) { - return null - } - return ( -
-
{vo.role === 'question' ? 'You' : chat.label}
-
- -
-
- ) - })} -
- ) - } + } + data={chat.list || []} + streamLoading={compareLoading} + labelPosition="top" + labelFormat={(item) => item.role === 'user' ? 'You' : chat.label} + errorDesc={t('application.ReplyException')} + /> + ))} -
+
- + - + })} onClick={isCluster ? handleClusterSend : handleSend} />
} diff --git a/web/src/views/ApplicationConfig/types.ts b/web/src/views/ApplicationConfig/types.ts index 607110a6..f910a90f 100644 --- a/web/src/views/ApplicationConfig/types.ts +++ b/web/src/views/ApplicationConfig/types.ts @@ -1,4 +1,5 @@ import type { KnowledgeBaseListItem } from '@/views/KnowledgeBase/types' +import type { ChatItem } from '@/components/Chat/types' export interface ModelConfig { label?: string; @@ -139,11 +140,6 @@ export interface ApiExtensionModalData { export interface ApiExtensionModalRef { handleOpen: () => void; } -export interface ChatItem { - role: 'answer' | 'question'; - content?: string; - time: number; -} export interface ChatData { label?: string; model_config_id?: string; diff --git a/web/src/views/Conversation/index.tsx b/web/src/views/Conversation/index.tsx index 149b2261..e19f4f06 100644 --- a/web/src/views/Conversation/index.tsx +++ b/web/src/views/Conversation/index.tsx @@ -2,16 +2,24 @@ import { type FC, useState, useEffect, useRef } from 'react' import { useParams, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import InfiniteScroll from 'react-infinite-scroll-component'; -import { Flex, Skeleton } from 'antd' +import { Flex, Skeleton, Form } from 'antd' import clsx from 'clsx' -import Chat, { type ChatItem } from '@/views/MemoryConversation/components/Chat' import AnalysisEmptyIcon from '@/assets/images/conversation/analysisEmpty.svg' import { getConversationHistory, sendConversation, getConversationDetail, getShareToken } from '@/api/application' -import type { HistoryItem } from './types' +import type { HistoryItem, QueryParams } from './types' import Empty from '@/components/Empty' import { formatDateTime } from '@/utils/format'; import { randomString } from '@/utils/common' import BgImg from '@/assets/images/conversation/bg.png' +import Chat from '@/components/Chat' +import type { ChatItem } from '@/components/Chat/types' +import ButtonCheckbox from '@/components/ButtonCheckbox' +import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg' +import OnlineIcon from '@/assets/images/conversation/online.svg' +import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg' +import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg' +import dayjs from 'dayjs' +import { type SSEMessage } from '@/utils/stream' const Conversation: FC = () => { const { t } = useTranslation() @@ -20,13 +28,8 @@ const Conversation: FC = () => { const searchParams = new URLSearchParams(location.search) const userId = searchParams.get('user_id') const [loading, setLoading] = useState(false) - const [chatLoading, setChatLoading] = useState(false) - const [query, setQuery] = useState<{ - message?: string; - web_search?: boolean; - memory?: boolean; - conversation_id?: string; - }>({}) + const [streamLoading, setStreamLoading] = useState(false) + const [message, setMessage] = useState('') const [conversation_id, setConversationId] = useState(null) const [historyList, setHistoryList] = useState([]) const [groupHistoryList, setGroupHistoryList] = useState>({}) @@ -36,14 +39,18 @@ const Conversation: FC = () => { const [hasMore, setHasMore] = useState(true); const scrollRef = useRef(null); const [shareToken, setShareToken] = useState(localStorage.getItem(`shareToken_${token}`)) + + const [form] = Form.useForm() + const queryValues = Form.useWatch([], form) useEffect(() => { const shareToken = localStorage.getItem(`shareToken_${token}`) setShareToken(shareToken) if (shareToken && shareToken !== '') return getShareToken(token as string, userId || randomString(12, false)) .then(res => { - localStorage.setItem(`shareToken_${token}`, res?.access_token || '') - setShareToken(res?.access_token || '') + const response = res as { access_token: string } || {} + localStorage.setItem(`shareToken_${token}`, response.access_token ?? '') + setShareToken(response.access_token ?? '') }) }, [token]) @@ -73,7 +80,7 @@ const Conversation: FC = () => { setPageLoading(true); getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 }) .then(res => { - const response = res as { items: HistoryItem[], page: { hasnext: boolean } } + const response = res as { items: HistoryItem[], page: { hasnext: boolean; page: number; pagesize: number; total: number } } const results = response?.items || [] let list = [] if (flag) { @@ -101,7 +108,7 @@ const Conversation: FC = () => { setConversationId(id) } if (!id) { - setQuery({}) + setMessage('') } } useEffect(() => { @@ -116,72 +123,81 @@ const Conversation: FC = () => { } }, [conversation_id]) + const addUserMessage = (message: string = '') => { + const newUserMessage: ChatItem = { + conversation_id, + role: 'user', + content: message, + created_at: Date.now() + }; + setChatList(prev => [...prev, newUserMessage]) + } + const addAssistantMessage = () => { + const newAssistantMessage: ChatItem = { + created_at: Date.now(), + role: 'assistant', + content: '', + } + setChatList(prev => [...prev, newAssistantMessage]) + } + const updateAssistantMessage = (content: string = '') => { + if (!content) return + if (streamLoading) { + setStreamLoading(false) + } + + setChatList(prev => { + const lastList = [...prev] + const lastIndex = lastList.length - 1 + const lastMsg = lastList[lastIndex] + if (lastMsg?.role === 'assistant') { + return [ + ...lastList.slice(0, lastList.length - 1), + { + ...lastMsg, + content: lastMsg.content + content + } + ] + } + return prev + }) + } + const handleSend = () => { if (!token || !shareToken) { return } - // 添加必需的id和conversation_id属性 - const newUserMessage: ChatItem = { - conversation_id, - role: 'user', - content: query?.message || '', - created_at: Date.now() - }; - setChatList(prev => [...prev, newUserMessage]) - setLoading(true) - setChatLoading(true) - setChatList(prev => [...prev, { - created_at: Date.now(), - role: 'assistant', - content: '', - }]) - let currentConversationId: string | null = null - const handleStreamMessage = (data: string) => { - setChatLoading(false) - try { - const lines = data.split('\n'); - let currentEvent = ''; + setStreamLoading(true) + addUserMessage(message) + addAssistantMessage() - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - if (line.startsWith('event:')) { - currentEvent = line.substring(6).trim(); - } else if (line.startsWith('data:') && currentEvent === 'message') { - const jsonData = line.substring(5).trim(); - const parsed = JSON.parse(jsonData); - - if (parsed.content) { - setChatList(prev => prev.map((msg, msgIndex) => { - if (msgIndex === prev!.length - 1 && msg.role === 'assistant') { - return { ...msg, content: msg.content + parsed.content }; - } - return msg; - })) - } - } else if (line.startsWith('data:') && currentEvent === 'start') { - const jsonData = line.substring(5).trim(); - const parsed = JSON.parse(jsonData); - currentConversationId = parsed.conversation_id - } else if (currentEvent === 'end') { - setLoading(false); + let currentConversationId: string | null = null + const handleStreamMessage = (data: SSEMessage[]) => { + data.forEach((item) => { + switch(item.event) { + case 'start': + const { conversation_id: newId } = item.data as { conversation_id: string } + currentConversationId = newId + break + case 'message': + const { content } = item.data as { content: string } + updateAssistantMessage(content) + break + case 'end': + setLoading(false) if (currentConversationId && currentConversationId !== conversation_id) { setConversationId(currentConversationId) - getHistory(true) } - } + getHistory(true) + break } - } catch (e) { - console.error('Parse stream data error:', e); - } + }) }; - sendConversation(token as string, { - message: query?.message || '', - web_search: query?.web_search || false, - memory: query?.memory || false, + sendConversation({ + ...queryValues, + message: message || '', stream: true, conversation_id: conversation_id || null, }, handleStreamMessage, shareToken) @@ -192,12 +208,12 @@ const Conversation: FC = () => { return ( -
-
+
handleChangeHistory(null)} >
{t('memoryConversation.startANewConversation')}
@@ -216,11 +232,11 @@ const Conversation: FC = () => { scrollableTarget="scrollableDiv" > {Object.entries(groupHistoryList).map(([date, items]) => ( -
-
{date.replace(/\u200e|\u200f/g, '')}
+
+
{date.replace(/\u200e|\u200f/g, '')}
{items.map(item => ( -
-
+
handleChangeHistory(item.id)} @@ -237,18 +253,38 @@ const Conversation: FC = () => {
-
+
- } - query={query} + empty={} + contentClassName="rb:h-[calc(100%-152px)]" data={chatList} + streamLoading={streamLoading} loading={loading} - onChange={setQuery} + onChange={setMessage} onSend={handleSend} - /> + labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} + > +
+ + + + {t(`memoryConversation.web_search`)} + + + + + {t(`memoryConversation.memory`)} + + + +
+
) diff --git a/web/src/views/Conversation/types.ts b/web/src/views/Conversation/types.ts index f1fb199e..b1f28879 100644 --- a/web/src/views/Conversation/types.ts +++ b/web/src/views/Conversation/types.ts @@ -10,4 +10,12 @@ export interface HistoryItem { is_active: boolean; created_at: number; updated_at: number; +} + +export interface QueryParams { + message?: string; + web_search?: boolean; + memory?: boolean; + stream: boolean; + conversation_id?: string | null; } \ No newline at end of file diff --git a/web/src/views/MemoryConversation/components/Chat.tsx b/web/src/views/MemoryConversation/components/Chat.tsx deleted file mode 100644 index 7b1b36c4..00000000 --- a/web/src/views/MemoryConversation/components/Chat.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { type FC, type ReactNode, useEffect, useRef, useState } from 'react' -import { Flex } from 'antd' -import clsx from 'clsx' -import ChatInput from './ChatInput' -import type { TestParams } from '../index' -import dayjs from 'dayjs' -import Markdown from '@/components/Markdown' - -interface ChatProps { - empty?: ReactNode; - data: ChatItem[]; - query?: TestParams; - onChange: (query: TestParams) => void; - onSend: () => void; - loading: boolean; - source?: 'conversation' | 'memory'; -} -export interface ChatItem { - id?: string; - conversation_id?: string | null; - role?: 'user' | 'assistant'; - content?: string; - message?: string; - created_at?: number | string; - meta_data?: Record[]; -} - -const Chat: FC = ({ empty, data, query, onChange, onSend, loading, source = 'memory' }) => { - const scrollContainerRefs = useRef<(HTMLDivElement | null)>(null) - const [isMemory, setIsMemory] = useState(source === 'memory') - - useEffect(() => { - setIsMemory(source === 'memory') - }, [source]) - useEffect(() => { - setTimeout(() => { - if (scrollContainerRefs.current) { - scrollContainerRefs.current.scrollTop = scrollContainerRefs.current.scrollHeight; - } - }, 0); - }, [data]) - - return ( -
- {data.length === 0 ? ( - - {/* Empty */} -
- {empty} -
- - -
- ) - : ( -
- {data.map((item, index) => ( -
-
- -
-
{dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
-
- ))} -
- )} - - -
- ) -} -export default Chat diff --git a/web/src/views/MemoryConversation/components/ChatInput.tsx b/web/src/views/MemoryConversation/components/ChatInput.tsx deleted file mode 100644 index 2b98f515..00000000 --- a/web/src/views/MemoryConversation/components/ChatInput.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { type FC, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Flex, Input, Form } from 'antd' -import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg' -import OnlineIcon from '@/assets/images/conversation/online.svg' -import DeepThinkingIcon from '@/assets/images/conversation/deepThinking.svg' -import SendIcon from '@/assets/images/conversation/send.svg' -import SendDisabledIcon from '@/assets/images/conversation/sendDisabled.svg' -import ButtonCheckbox from '@/components/ButtonCheckbox' -import DeepThinkingCheckedIcon from '@/assets/images/conversation/deepThinkingChecked.svg' -import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg' -import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg' -import LoadingIcon from '@/assets/images/conversation/loading.svg' -import type { TestParams } from '../index' - -interface ChatInputProps { - query?: TestParams; - onChange: (query: TestParams) => void; - onSend: () => void; - loading: boolean; - source: 'conversation' | 'memory'; -} -const searchSwitchList = [ - { - icon: DeepThinkingIcon, - checkedIcon: DeepThinkingCheckedIcon, - value: '0', - label: 'deepThinking' // 深度思考 - }, - { - icon: MemoryFunctionIcon, - checkedIcon: MemoryFunctionCheckedIcon, - value: '1', - label: 'normalReply' // 普通回复 - }, - { - icon: OnlineIcon, - checkedIcon: OnlineCheckedIcon, - value: '2', - label: 'quickReply' // 快速回复 - }, -] - -const ChatInput: FC = ({ source,query, onChange, onSend, loading }) => { - const [form] = Form.useForm() - const { t } = useTranslation(); - const values = Form.useWatch([], form); - const [search_switch, setSearchSwitch] = useState('0') - - useEffect(() => { - if (onChange) { - onChange({...values, search_switch}) - } - }, [values, search_switch, onChange]) - useEffect(() => { - if (!query?.message) { - form.setFieldsValue({ - message: undefined, - }) - } - }, [form, query?.message]) - useEffect(() => { - if (loading) { - form.setFieldsValue({ - message: undefined, - }) - } - }, [loading]) - - const handleChange = (value: string) => { - form.setFieldsValue({ - search_switch: value, - }) - setSearchSwitch(value) - } - - return ( -
- - - onChange({ ...query, message: e.target.value })} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && e.target.value?.trim() !== '' && !loading) { - e.preventDefault(); - onSend(); - } - }} - /> - - - - {source === 'memory' && - - {searchSwitchList.map(item => ( - handleChange(item.value)} - > - {t(`memoryConversation.${item.label}`)} - - ))} - - } - {source === 'conversation' && - - - - {t(`memoryConversation.web_search`)} - - - - - {t(`memoryConversation.memory`)} - - - - } - {loading ? : - !values || !values?.message || values?.message?.trim() === '' ? - - : - } - - -
- ) -} - -export default ChatInput diff --git a/web/src/views/MemoryConversation/index.tsx b/web/src/views/MemoryConversation/index.tsx index c92044cc..bb8df5e4 100644 --- a/web/src/views/MemoryConversation/index.tsx +++ b/web/src/views/MemoryConversation/index.tsx @@ -1,16 +1,48 @@ import { type FC, type ReactNode, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Col, Row, App, Skeleton, Space, Select } from 'antd' +import { Col, Row, App, Skeleton, Space, Select, Flex } from 'antd' import clsx from 'clsx' import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg' import AnalysisEmptyIcon from '@/assets/images/conversation/analysisEmpty.png' import Card from './components/Card' -import Chat from './components/Chat' import { readService, getUserMemoryList } from '@/api/memory' import Empty from '@/components/Empty' import Markdown from '@/components/Markdown' import type { Data } from '@/views/UserMemory/types' +import Chat from '@/components/Chat' +import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg' +import OnlineIcon from '@/assets/images/conversation/online.svg' +import DeepThinkingIcon from '@/assets/images/conversation/deepThinking.svg' +import ButtonCheckbox from '@/components/ButtonCheckbox' +import DeepThinkingCheckedIcon from '@/assets/images/conversation/deepThinkingChecked.svg' +import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg' +import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg' +import type { ChatItem } from '@/components/Chat/types' +import dayjs from 'dayjs' +import type { AnyObject } from 'antd/es/_util/type'; + + +const searchSwitchList = [ + { + icon: DeepThinkingIcon, + checkedIcon: DeepThinkingCheckedIcon, + value: '0', + label: 'deepThinking' // 深度思考 + }, + { + icon: MemoryFunctionIcon, + checkedIcon: MemoryFunctionCheckedIcon, + value: '1', + label: 'normalReply' // 普通回复 + }, + { + icon: OnlineIcon, + checkedIcon: OnlineCheckedIcon, + value: '2', + label: 'quickReply' // 快速回复 + }, +] export interface TestParams { group_id: string; @@ -30,8 +62,8 @@ interface DataItem { export interface LogItem { type: string; title: string; - data?: DataItem[] | Record; - raw_results?: string; + data?: DataItem[] | AnyObject; + raw_results?: string | AnyObject; summary?: string; query?: string; reason?: string; @@ -41,7 +73,7 @@ export interface LogItem { } const ContentWrapper: FC<{ children: ReactNode }> = ({ children }) => ( -
+
{children}
) @@ -49,17 +81,13 @@ const ContentWrapper: FC<{ children: ReactNode }> = ({ children }) => ( const MemoryConversation: FC = () => { const { t } = useTranslation() const { message } = App.useApp(); - const [query, setQuery] = useState({ - group_id: '', - message: '', - search_switch: '0', - history: [], - }) const [userId, setUserId] = useState() const [loading, setLoading] = useState(false) - const [chatData, setChatData] = useState<{ content: string; created_at: string | number; role: string }[]>([]) + const [chatData, setChatData] = useState([]) const [logs, setLogs] = useState([]) const [userList, setUserList] = useState([]) + const [search_switch, setSearchSwitch] = useState('0') + const [msg, setMsg] = useState('') useEffect(() => { getUserMemoryList().then(res => { @@ -75,11 +103,12 @@ const MemoryConversation: FC = () => { message.warning(t('common.inputPlaceholder', { title: t('memoryConversation.userID') })) return } - setChatData(prev => [...prev, { content: query.message || '', created_at: new Date().getTime(), role: 'user' }]) + setChatData(prev => [...prev, { content: msg, created_at: new Date().getTime(), role: 'user' }]) setLoading(true) readService({ - ...query, + message: msg, group_id: userId, + search_switch: search_switch, history: [], }) .then(res => { @@ -92,6 +121,10 @@ const MemoryConversation: FC = () => { }) } + const handleChange = (value: string) => { + setSearchSwitch(value) + } + return ( <> @@ -101,7 +134,7 @@ const MemoryConversation: FC = () => { value: item.end_user?.id, label: item?.name, }))} - filterOption={(inputValue, option) => option.label?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1} + filterOption={(inputValue, option) => option?.label?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1} showSearch={true} // filterOption={(inputValue, option) => option.label?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1} placeholder={t('memoryConversation.searchPlaceholder')} @@ -118,14 +151,29 @@ const MemoryConversation: FC = () => { > + } + contentClassName='rb:h-[calc(100vh-362px)]' data={chatData} - query={query} - onChange={setQuery} + onChange={setMsg} onSend={handleSend} loading={loading} - /> + labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} + > + + {searchSwitchList.map(item => ( + handleChange(item.value)} + > + {t(`memoryConversation.${item.label}`)} + + ))} + + @@ -147,8 +195,8 @@ const MemoryConversation: FC = () => { {logs.map((log, logIndex) => (
{ } )} > -
{log.title}
+
{log.title}
{log.type === 'problem_split' && Array.isArray(log.data) && log.data.length > 0 ? {log.data.map(vo => ( <>
{vo.id}. {vo.question}
-
{vo.reason}
+
{vo.reason}
))} @@ -175,7 +223,7 @@ const MemoryConversation: FC = () => { <>
{key}
{(log.data as Record)[key].map((item, index) => ( -
{item}
+
{item}
))} @@ -183,15 +231,15 @@ const MemoryConversation: FC = () => {
: log.type === 'search_result' && log.raw_results ? -
{log.query}
-
+
{log.query}
+
{typeof log.raw_results === 'string' ? : <> - {log.raw_results.reranked_results?.statements.length > 0 && log.raw_results.reranked_results?.statements.map((item, index) => ( + {log.raw_results.reranked_results?.statements.length > 0 && log.raw_results.reranked_results?.statements.map((item: { statement: string }, index: number) => (
{item.statement}
))} - {log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item, index) => ( + {log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item: { content: string }, index: number) => (
{item.content}
))} @@ -203,26 +251,26 @@ const MemoryConversation: FC = () => { : log.type === 'verification' ?
{log.query}
-
{log.reason}
-
{log.result}
+
{log.reason}
+
{log.result}
: log.type === 'output_type' ? -
{log.query}
+
{log.query}
{log.summary}
: log.type === 'input_summary' && log.raw_results ? -
{log.query}
-
{log.summary}
-
+
{log.query}
+
{log.summary}
+
{typeof log.raw_results === 'string' ? : <> - {log.raw_results.reranked_results?.statements.length > 0 && log.raw_results.reranked_results?.statements.map((item, index) => ( + {log.raw_results.reranked_results?.statements.length > 0 && log.raw_results.reranked_results?.statements.map((item: { statement: string; } , index: number) => (
{item.statement}
))} - {log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item, index) => ( + {log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item: { content: string; }, index: number) => (
{item.content}
))}