### What problem does this PR solve? Feat: Add agent log-sheet in cavas and log-sheet in share's page #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| @@ -17,7 +17,9 @@ import { | |||
| useFetchDocumentThumbnailsByIds, | |||
| } from '@/hooks/document-hooks'; | |||
| import { IRegenerateMessage, IRemoveMessageById } from '@/hooks/logic-hooks'; | |||
| import { INodeEvent } from '@/hooks/use-send-message'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { WorkFlowTimeline } from '@/pages/agent/log-sheet/workFlowTimeline'; | |||
| import { IMessage } from '@/pages/chat/interface'; | |||
| import { getExtension, isImage } from '@/utils/document-util'; | |||
| import { Avatar, Button, Flex, List, Space, Typography } from 'antd'; | |||
| @@ -38,6 +40,9 @@ interface IProps | |||
| IRegenerateMessage, | |||
| PropsWithChildren { | |||
| item: IMessage; | |||
| conversationId?: string; | |||
| currentEventListWithoutMessageById?: (messageId: string) => INodeEvent[]; | |||
| setCurrentMessageId?: (messageId: string) => void; | |||
| reference?: IReferenceObject; | |||
| loading?: boolean; | |||
| sendLoading?: boolean; | |||
| @@ -54,6 +59,9 @@ interface IProps | |||
| function MessageItem({ | |||
| item, | |||
| conversationId, | |||
| currentEventListWithoutMessageById, | |||
| setCurrentMessageId, | |||
| reference, | |||
| loading = false, | |||
| avatar, | |||
| @@ -106,6 +114,11 @@ function MessageItem({ | |||
| } | |||
| }, [item.doc_ids, setDocumentIds, setIds, documentThumbnails]); | |||
| useEffect(() => { | |||
| if (typeof setCurrentMessageId === 'function') { | |||
| setCurrentMessageId(item.id); | |||
| } | |||
| }, [item.id, setCurrentMessageId]); | |||
| return ( | |||
| <div | |||
| className={classNames(styles.messageItem, { | |||
| @@ -186,6 +199,15 @@ function MessageItem({ | |||
| list={referenceDocuments} | |||
| ></ReferenceDocumentList> | |||
| )} | |||
| {isAssistant && currentEventListWithoutMessageById && ( | |||
| <WorkFlowTimeline | |||
| currentEventListWithoutMessage={currentEventListWithoutMessageById( | |||
| item.id, | |||
| )} | |||
| currentMessageId={item.id} | |||
| canvasId={conversationId} | |||
| /> | |||
| )} | |||
| {isUser && documentList.length > 0 && ( | |||
| <List | |||
| bordered | |||
| @@ -7,7 +7,7 @@ import { IDebugSingleRequestBody } from '@/interfaces/request/agent'; | |||
| import i18n from '@/locales/config'; | |||
| import { BeginId } from '@/pages/agent/constant'; | |||
| import { useGetSharedChatSearchParams } from '@/pages/chat/shared-hooks'; | |||
| import agentService from '@/services/agent-service'; | |||
| import agentService, { fetchTrace } from '@/services/agent-service'; | |||
| import api from '@/utils/api'; | |||
| import { buildMessageListWithUuid } from '@/utils/chat'; | |||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | |||
| @@ -390,8 +390,12 @@ export const useUploadCanvasFileWithProgress = ( | |||
| return { data, loading, uploadCanvasFile: mutateAsync }; | |||
| }; | |||
| export const useFetchMessageTrace = () => { | |||
| export const useFetchMessageTrace = ( | |||
| isStopFetchTrace: boolean, | |||
| canvasId?: string, | |||
| ) => { | |||
| const { id } = useParams(); | |||
| const queryId = id || canvasId; | |||
| const [messageId, setMessageId] = useState(''); | |||
| const { | |||
| @@ -399,16 +403,16 @@ export const useFetchMessageTrace = () => { | |||
| isFetching: loading, | |||
| refetch, | |||
| } = useQuery<ITraceData[]>({ | |||
| queryKey: [AgentApiAction.Trace, id, messageId], | |||
| queryKey: [AgentApiAction.Trace, queryId, messageId], | |||
| refetchOnReconnect: false, | |||
| refetchOnMount: false, | |||
| refetchOnWindowFocus: false, | |||
| gcTime: 0, | |||
| enabled: !!id && !!messageId, | |||
| refetchInterval: 3000, | |||
| enabled: !!queryId && !!messageId, | |||
| refetchInterval: !isStopFetchTrace ? 3000 : false, | |||
| queryFn: async () => { | |||
| const { data } = await agentService.trace({ | |||
| canvas_id: id, | |||
| const { data } = await fetchTrace({ | |||
| canvas_id: queryId as string, | |||
| message_id: messageId, | |||
| }); | |||
| @@ -29,6 +29,8 @@ export interface INodeData { | |||
| inputs: Record<string, any>; | |||
| outputs: Record<string, any>; | |||
| component_id: string; | |||
| component_name: string; | |||
| component_type: string; | |||
| error: null | string; | |||
| elapsed_time: number; | |||
| created_at: number; | |||
| @@ -124,7 +124,7 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { | |||
| const { | |||
| addEventList, | |||
| setCurrentMessageId, | |||
| currentEventListWithoutMessage, | |||
| currentEventListWithoutMessageById, | |||
| clearEventList, | |||
| currentMessageId, | |||
| } = useCacheChatLog(); | |||
| @@ -258,7 +258,9 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { | |||
| {logSheetVisible && ( | |||
| <LogSheet | |||
| hideModal={hideLogSheet} | |||
| currentEventListWithoutMessage={currentEventListWithoutMessage} | |||
| currentEventListWithoutMessageById={ | |||
| currentEventListWithoutMessageById | |||
| } | |||
| currentMessageId={currentMessageId} | |||
| ></LogSheet> | |||
| )} | |||
| @@ -169,13 +169,20 @@ export function useSetUploadResponseData() { | |||
| }; | |||
| } | |||
| export const useSendAgentMessage = (url?: string) => { | |||
| export const useSendAgentMessage = ( | |||
| url?: string, | |||
| addEventList?: (data: IEventList, messageId: string) => void, | |||
| ) => { | |||
| const { id: agentId } = useParams(); | |||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | |||
| const inputs = useSelectBeginNodeDataInputs(); | |||
| const { send, answerList, done, stopOutputMessage } = useSendMessageBySSE( | |||
| url || api.runCanvas, | |||
| ); | |||
| const messageId = useMemo(() => { | |||
| return answerList[0]?.message_id; | |||
| }, [answerList]); | |||
| const { findReferenceByMessageId } = useFindMessageReference(answerList); | |||
| const prologue = useGetBeginNodePrologue(); | |||
| const { | |||
| @@ -186,7 +193,7 @@ export const useSendAgentMessage = (url?: string) => { | |||
| addNewestOneQuestion, | |||
| addNewestOneAnswer, | |||
| } = useSelectDerivedMessages(); | |||
| const { addEventList } = useContext(AgentChatLogContext); | |||
| const { addEventList: addEventListFun } = useContext(AgentChatLogContext); | |||
| const { | |||
| appendUploadResponseList, | |||
| clearUploadResponseList, | |||
| @@ -287,9 +294,11 @@ export const useSendAgentMessage = (url?: string) => { | |||
| useEffect(() => { | |||
| if (typeof addEventList === 'function') { | |||
| addEventList(answerList); | |||
| addEventList(answerList, messageId); | |||
| } else if (typeof addEventListFun === 'function') { | |||
| addEventListFun(answerList, messageId); | |||
| } | |||
| }, [addEventList, answerList]); | |||
| }, [addEventList, answerList, addEventListFun, messageId]); | |||
| return { | |||
| value, | |||
| @@ -3,7 +3,7 @@ import { | |||
| INodeEvent, | |||
| MessageEventType, | |||
| } from '@/hooks/use-send-message'; | |||
| import { useCallback, useMemo, useState } from 'react'; | |||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | |||
| export const ExcludeTypes = [ | |||
| MessageEventType.Message, | |||
| @@ -12,51 +12,76 @@ export const ExcludeTypes = [ | |||
| export function useCacheChatLog() { | |||
| const [eventList, setEventList] = useState<IEventList>([]); | |||
| const [messageIdPool, setMessageIdPool] = useState< | |||
| Record<string, IEventList> | |||
| >({}); | |||
| const [currentMessageId, setCurrentMessageId] = useState(''); | |||
| useEffect(() => { | |||
| setMessageIdPool((prev) => ({ ...prev, [currentMessageId]: eventList })); | |||
| }, [currentMessageId, eventList]); | |||
| const filterEventListByMessageId = useCallback( | |||
| (messageId: string) => { | |||
| return eventList.filter((x) => x.message_id === messageId); | |||
| return messageIdPool[messageId]?.filter( | |||
| (x) => x.message_id === messageId, | |||
| ); | |||
| }, | |||
| [eventList], | |||
| [messageIdPool], | |||
| ); | |||
| const filterEventListByEventType = useCallback( | |||
| (eventType: string) => { | |||
| return eventList.filter((x) => x.event === eventType); | |||
| return messageIdPool[currentMessageId]?.filter( | |||
| (x) => x.event === eventType, | |||
| ); | |||
| }, | |||
| [eventList], | |||
| [messageIdPool, currentMessageId], | |||
| ); | |||
| const clearEventList = useCallback(() => { | |||
| setEventList([]); | |||
| }, []); | |||
| const addEventList = useCallback((events: IEventList) => { | |||
| setEventList((list) => { | |||
| const nextList = [...list]; | |||
| const addEventList = useCallback( | |||
| (events: IEventList, message_id: string) => { | |||
| const nextList = [...eventList]; | |||
| events.forEach((x) => { | |||
| if (nextList.every((y) => y !== x)) { | |||
| nextList.push(x); | |||
| } | |||
| }); | |||
| return nextList; | |||
| }); | |||
| }, []); | |||
| setEventList(nextList); | |||
| setMessageIdPool((prev) => ({ ...prev, [message_id]: nextList })); | |||
| }, | |||
| [eventList], | |||
| ); | |||
| const currentEventListWithoutMessage = useMemo(() => { | |||
| const list = eventList.filter( | |||
| const list = messageIdPool[currentMessageId]?.filter( | |||
| (x) => | |||
| x.message_id === currentMessageId && | |||
| ExcludeTypes.every((y) => y !== x.event), | |||
| ); | |||
| return list as INodeEvent[]; | |||
| }, [currentMessageId, eventList]); | |||
| }, [currentMessageId, messageIdPool]); | |||
| const currentEventListWithoutMessageById = useCallback( | |||
| (messageId: string) => { | |||
| const list = messageIdPool[messageId]?.filter( | |||
| (x) => | |||
| x.message_id === messageId && | |||
| ExcludeTypes.every((y) => y !== x.event), | |||
| ); | |||
| return list as INodeEvent[]; | |||
| }, | |||
| [messageIdPool], | |||
| ); | |||
| return { | |||
| eventList, | |||
| currentEventListWithoutMessage, | |||
| currentEventListWithoutMessageById, | |||
| setEventList, | |||
| clearEventList, | |||
| addEventList, | |||
| @@ -1,156 +1,26 @@ | |||
| import { | |||
| Timeline, | |||
| TimelineContent, | |||
| TimelineHeader, | |||
| TimelineIndicator, | |||
| TimelineItem, | |||
| TimelineSeparator, | |||
| } from '@/components/originui/timeline'; | |||
| import { | |||
| Accordion, | |||
| AccordionContent, | |||
| AccordionItem, | |||
| AccordionTrigger, | |||
| } from '@/components/ui/accordion'; | |||
| import { | |||
| Sheet, | |||
| SheetContent, | |||
| SheetHeader, | |||
| SheetTitle, | |||
| } from '@/components/ui/sheet'; | |||
| import { useFetchMessageTrace } from '@/hooks/use-agent-request'; | |||
| import { | |||
| INodeData, | |||
| INodeEvent, | |||
| MessageEventType, | |||
| } from '@/hooks/use-send-message'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { ITraceData } from '@/interfaces/database/agent'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { t } from 'i18next'; | |||
| import { get } from 'lodash'; | |||
| import { NotebookText } from 'lucide-react'; | |||
| import { useCallback, useEffect, useMemo } from 'react'; | |||
| import JsonView from 'react18-json-view'; | |||
| import 'react18-json-view/src/style.css'; | |||
| import { Operator } from '../constant'; | |||
| import { useCacheChatLog } from '../hooks/use-cache-chat-log'; | |||
| import OperatorIcon from '../operator-icon'; | |||
| import useGraphStore from '../store'; | |||
| import { WorkFlowTimeline } from './workFlowTimeline'; | |||
| type LogSheetProps = IModalProps<any> & | |||
| Pick< | |||
| ReturnType<typeof useCacheChatLog>, | |||
| 'currentEventListWithoutMessage' | 'currentMessageId' | |||
| 'currentEventListWithoutMessageById' | 'currentMessageId' | |||
| >; | |||
| function JsonViewer({ | |||
| data, | |||
| title, | |||
| }: { | |||
| data: Record<string, any>; | |||
| title: string; | |||
| }) { | |||
| return ( | |||
| <section className="space-y-2"> | |||
| <div>{title}</div> | |||
| <JsonView | |||
| src={data} | |||
| displaySize | |||
| collapseStringsAfterLength={100000000000} | |||
| className="w-full h-[200px] break-words overflow-auto p-2 bg-slate-800" | |||
| /> | |||
| </section> | |||
| ); | |||
| } | |||
| function getInputsOrOutputs( | |||
| nodeEventList: INodeData[], | |||
| field: 'inputs' | 'outputs', | |||
| ) { | |||
| const inputsOrOutputs = nodeEventList.map((x) => get(x, field, {})); | |||
| if (inputsOrOutputs.length < 2) { | |||
| return inputsOrOutputs[0] || {}; | |||
| } | |||
| return inputsOrOutputs; | |||
| } | |||
| export function LogSheet({ | |||
| hideModal, | |||
| currentEventListWithoutMessage, | |||
| currentEventListWithoutMessageById, | |||
| currentMessageId, | |||
| }: LogSheetProps) { | |||
| const getNode = useGraphStore((state) => state.getNode); | |||
| const { data: traceData, setMessageId } = useFetchMessageTrace(); | |||
| useEffect(() => { | |||
| setMessageId(currentMessageId); | |||
| }, [currentMessageId, setMessageId]); | |||
| const getNodeName = useCallback( | |||
| (nodeId: string) => { | |||
| if ('begin' === nodeId) return t('flow.begin'); | |||
| return getNode(nodeId)?.data.name; | |||
| }, | |||
| [getNode], | |||
| ); | |||
| const startedNodeList = useMemo(() => { | |||
| const duplicateList = currentEventListWithoutMessage.filter( | |||
| (x) => x.event === MessageEventType.NodeStarted, | |||
| ) as INodeEvent[]; | |||
| // Remove duplicate nodes | |||
| return duplicateList.reduce<Array<INodeEvent>>((pre, cur) => { | |||
| if (pre.every((x) => x.data.component_id !== cur.data.component_id)) { | |||
| pre.push(cur); | |||
| } | |||
| return pre; | |||
| }, []); | |||
| }, [currentEventListWithoutMessage]); | |||
| const hasTrace = useCallback( | |||
| (componentId: string) => { | |||
| if (Array.isArray(traceData)) { | |||
| return traceData?.some((x) => x.component_id === componentId); | |||
| } | |||
| return false; | |||
| }, | |||
| [traceData], | |||
| ); | |||
| const filterTrace = useCallback( | |||
| (componentId: string) => { | |||
| const trace = traceData | |||
| ?.filter((x) => x.component_id === componentId) | |||
| .reduce<ITraceData['trace']>((pre, cur) => { | |||
| pre.push(...cur.trace); | |||
| return pre; | |||
| }, []); | |||
| return Array.isArray(trace) ? trace : {}; | |||
| }, | |||
| [traceData], | |||
| ); | |||
| const filterFinishedNodeList = useCallback( | |||
| (componentId: string) => { | |||
| const nodeEventList = currentEventListWithoutMessage | |||
| .filter( | |||
| (x) => | |||
| x.event === MessageEventType.NodeFinished && | |||
| (x.data as INodeData)?.component_id === componentId, | |||
| ) | |||
| .map((x) => x.data); | |||
| return nodeEventList; | |||
| }, | |||
| [currentEventListWithoutMessage], | |||
| ); | |||
| return ( | |||
| <Sheet open onOpenChange={hideModal} modal={false}> | |||
| <SheetContent className="top-20 right-[620px]"> | |||
| @@ -161,96 +31,12 @@ export function LogSheet({ | |||
| </SheetTitle> | |||
| </SheetHeader> | |||
| <section className="max-h-[82vh] overflow-auto mt-6"> | |||
| <Timeline> | |||
| {startedNodeList.map((x, idx) => { | |||
| const nodeDataList = filterFinishedNodeList(x.data.component_id); | |||
| const finishNodeIds = nodeDataList.map( | |||
| (x: INodeData) => x.component_id, | |||
| ); | |||
| const inputs = getInputsOrOutputs(nodeDataList, 'inputs'); | |||
| const outputs = getInputsOrOutputs(nodeDataList, 'outputs'); | |||
| const nodeLabel = getNode(x.data.component_id)?.data.label; | |||
| return ( | |||
| <TimelineItem | |||
| key={idx} | |||
| step={idx} | |||
| className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-8" | |||
| > | |||
| <TimelineHeader> | |||
| <TimelineSeparator className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5 top-6 bg-background-checked" /> | |||
| <TimelineIndicator className="bg-primary/10 group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground flex size-6 items-center justify-center border-none group-data-[orientation=vertical]/timeline:-left-7"> | |||
| <div className='relative after:content-[""] after:absolute after:inset-0 after:z-10 after:bg-transparent after:transition-all after:duration-300'> | |||
| <div className="absolute inset-0 z-10 flex items-center justify-center "> | |||
| <div | |||
| className={cn('rounded-full w-6 h-6', { | |||
| ' border-muted-foreground border-2 border-t-transparent animate-spin ': | |||
| !finishNodeIds.includes(x.data.component_id), | |||
| })} | |||
| ></div> | |||
| </div> | |||
| <div className="size-6 flex items-center justify-center"> | |||
| <OperatorIcon | |||
| className="size-5" | |||
| name={nodeLabel as Operator} | |||
| ></OperatorIcon> | |||
| </div> | |||
| </div> | |||
| </TimelineIndicator> | |||
| </TimelineHeader> | |||
| <TimelineContent className="text-foreground rounded-lg border mb-5"> | |||
| <section key={idx}> | |||
| <Accordion | |||
| type="single" | |||
| collapsible | |||
| className="bg-background-card px-3" | |||
| > | |||
| <AccordionItem value={idx.toString()}> | |||
| <AccordionTrigger> | |||
| <div className="flex gap-2 items-center"> | |||
| <span>{getNodeName(x.data?.component_id)}</span> | |||
| <span className="text-text-sub-title text-xs"> | |||
| {x.data.elapsed_time?.toString().slice(0, 6)} | |||
| </span> | |||
| <span | |||
| className={cn( | |||
| 'border-background -end-1 -top-1 size-2 rounded-full border-2 bg-dot-green', | |||
| { 'text-dot-green': x.data.error === null }, | |||
| { 'text-dot-red': x.data.error !== null }, | |||
| )} | |||
| > | |||
| <span className="sr-only">Online</span> | |||
| </span> | |||
| </div> | |||
| </AccordionTrigger> | |||
| <AccordionContent> | |||
| <div className="space-y-2"> | |||
| <JsonViewer | |||
| data={inputs} | |||
| title="Input" | |||
| ></JsonViewer> | |||
| {hasTrace(x.data.component_id) && ( | |||
| <JsonViewer | |||
| data={filterTrace(x.data.component_id)} | |||
| title={'Trace'} | |||
| ></JsonViewer> | |||
| )} | |||
| <JsonViewer | |||
| data={outputs} | |||
| title={'Output'} | |||
| ></JsonViewer> | |||
| </div> | |||
| </AccordionContent> | |||
| </AccordionItem> | |||
| </Accordion> | |||
| </section> | |||
| </TimelineContent> | |||
| </TimelineItem> | |||
| ); | |||
| })} | |||
| </Timeline> | |||
| <WorkFlowTimeline | |||
| currentEventListWithoutMessage={currentEventListWithoutMessageById( | |||
| currentMessageId, | |||
| )} | |||
| currentMessageId={currentMessageId} | |||
| /> | |||
| </section> | |||
| </SheetContent> | |||
| </Sheet> | |||
| @@ -0,0 +1,118 @@ | |||
| import { | |||
| TimelineContent, | |||
| TimelineHeader, | |||
| TimelineIndicator, | |||
| TimelineItem, | |||
| TimelineSeparator, | |||
| } from '@/components/originui/timeline'; | |||
| import { | |||
| Accordion, | |||
| AccordionContent, | |||
| AccordionItem, | |||
| AccordionTrigger, | |||
| } from '@/components/ui/accordion'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { Operator } from '../constant'; | |||
| import OperatorIcon from '../operator-icon'; | |||
| import { JsonViewer } from './workFlowTimeline'; | |||
| const ToolTimelineItem = ({ tools }: { tools: Record<string, any>[] }) => { | |||
| if (!tools || tools.length === 0 || !Array.isArray(tools)) return null; | |||
| const blackList = ['analyze_task', 'add_memory', 'gen_citations']; | |||
| const filteredTools = tools.filter( | |||
| (tool) => !blackList.includes(tool.tool_name), | |||
| ); | |||
| return ( | |||
| <> | |||
| {filteredTools?.map((tool, idx) => { | |||
| return ( | |||
| <TimelineItem | |||
| key={idx} | |||
| step={idx} | |||
| className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-8" | |||
| > | |||
| <TimelineHeader> | |||
| <TimelineSeparator | |||
| className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5 top-6" | |||
| style={{ | |||
| background: | |||
| idx < filteredTools.length - 1 | |||
| ? 'repeating-linear-gradient( to bottom, rgba(76, 164, 231, 1), rgba(76, 164, 231, 1) 5px, transparent 5px, transparent 10px' | |||
| : 'rgba(76, 164, 231, 1)', | |||
| width: '1px', | |||
| }} | |||
| /> | |||
| <TimelineIndicator | |||
| className={cn( | |||
| 'group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground flex size-6 p-1 items-center justify-center group-data-[orientation=vertical]/timeline:-left-7', | |||
| { | |||
| 'border border-blue-500': !( | |||
| idx >= filteredTools.length - 1 && tool.result === '...' | |||
| ), | |||
| }, | |||
| )} | |||
| > | |||
| <div className='relative after:content-[""] after:absolute after:inset-0 after:z-10 after:bg-transparent after:transition-all after:duration-300'> | |||
| <div className="absolute inset-0 z-10 flex items-center justify-center "> | |||
| <div | |||
| className={cn('rounded-full w-6 h-6', { | |||
| ' border-muted-foreground border-2 border-t-transparent animate-spin ': | |||
| idx >= filteredTools.length - 1 && | |||
| tool.result === '...', | |||
| })} | |||
| ></div> | |||
| </div> | |||
| <div className="size-6 flex items-center justify-center"> | |||
| <OperatorIcon | |||
| className="size-4" | |||
| name={'Agent' as Operator} | |||
| ></OperatorIcon> | |||
| </div> | |||
| </div> | |||
| </TimelineIndicator> | |||
| </TimelineHeader> | |||
| <TimelineContent className="text-foreground rounded-lg border mb-5"> | |||
| <section key={idx}> | |||
| <Accordion | |||
| type="single" | |||
| collapsible | |||
| className="bg-background-card px-3" | |||
| > | |||
| <AccordionItem value={idx.toString()}> | |||
| <AccordionTrigger> | |||
| <div className="flex gap-2 items-center"> | |||
| <span>{tool.tool_name}</span> | |||
| <span className="text-text-sub-title text-xs"> | |||
| {/* 0:00 | |||
| {x.data.elapsed_time?.toString().slice(0, 6)} */} | |||
| </span> | |||
| <span | |||
| className={cn( | |||
| 'border-background -end-1 -top-1 size-2 rounded-full border-2 bg-dot-green', | |||
| )} | |||
| > | |||
| <span className="sr-only">Online</span> | |||
| </span> | |||
| </div> | |||
| </AccordionTrigger> | |||
| <AccordionContent> | |||
| <div className="space-y-2"> | |||
| <JsonViewer | |||
| data={tool.result} | |||
| title="content" | |||
| ></JsonViewer> | |||
| </div> | |||
| </AccordionContent> | |||
| </AccordionItem> | |||
| </Accordion> | |||
| </section> | |||
| </TimelineContent> | |||
| </TimelineItem> | |||
| ); | |||
| })} | |||
| </> | |||
| ); | |||
| }; | |||
| export default ToolTimelineItem; | |||
| @@ -0,0 +1,264 @@ | |||
| import { | |||
| Timeline, | |||
| TimelineContent, | |||
| TimelineHeader, | |||
| TimelineIndicator, | |||
| TimelineItem, | |||
| TimelineSeparator, | |||
| } from '@/components/originui/timeline'; | |||
| import { | |||
| Accordion, | |||
| AccordionContent, | |||
| AccordionItem, | |||
| AccordionTrigger, | |||
| } from '@/components/ui/accordion'; | |||
| import { useFetchMessageTrace } from '@/hooks/use-agent-request'; | |||
| import { | |||
| INodeData, | |||
| INodeEvent, | |||
| MessageEventType, | |||
| } from '@/hooks/use-send-message'; | |||
| import { ITraceData } from '@/interfaces/database/agent'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { get } from 'lodash'; | |||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | |||
| import JsonView from 'react18-json-view'; | |||
| import { Operator } from '../constant'; | |||
| import { useCacheChatLog } from '../hooks/use-cache-chat-log'; | |||
| import OperatorIcon from '../operator-icon'; | |||
| import ToolTimelineItem from './toolTimelineItem'; | |||
| type LogFlowTimelineProps = Pick< | |||
| ReturnType<typeof useCacheChatLog>, | |||
| 'currentEventListWithoutMessage' | 'currentMessageId' | |||
| > & { canvasId?: string }; | |||
| export function JsonViewer({ | |||
| data, | |||
| title, | |||
| }: { | |||
| data: Record<string, any>; | |||
| title: string; | |||
| }) { | |||
| return ( | |||
| <section className="space-y-2"> | |||
| <div>{title}</div> | |||
| <JsonView | |||
| src={data} | |||
| displaySize | |||
| collapseStringsAfterLength={100000000000} | |||
| className="w-full h-[200px] break-words overflow-auto p-2 bg-slate-800" | |||
| /> | |||
| </section> | |||
| ); | |||
| } | |||
| function getInputsOrOutputs( | |||
| nodeEventList: INodeData[], | |||
| field: 'inputs' | 'outputs', | |||
| ) { | |||
| const inputsOrOutputs = nodeEventList.map((x) => get(x, field, {})); | |||
| if (inputsOrOutputs.length < 2) { | |||
| return inputsOrOutputs[0] || {}; | |||
| } | |||
| return inputsOrOutputs; | |||
| } | |||
| export const WorkFlowTimeline = ({ | |||
| currentEventListWithoutMessage, | |||
| currentMessageId, | |||
| canvasId, | |||
| }: LogFlowTimelineProps) => { | |||
| // const getNode = useGraphStore((state) => state.getNode); | |||
| const [isStopFetchTrace, setISStopFetchTrace] = useState(false); | |||
| const { data: traceData, setMessageId } = useFetchMessageTrace( | |||
| isStopFetchTrace, | |||
| canvasId, | |||
| ); | |||
| useEffect(() => { | |||
| setMessageId(currentMessageId); | |||
| }, [currentMessageId, setMessageId]); | |||
| // const getNodeName = useCallback( | |||
| // (nodeId: string) => { | |||
| // if ('begin' === nodeId) return t('flow.begin'); | |||
| // return getNode(nodeId)?.data.name; | |||
| // }, | |||
| // [getNode], | |||
| // ); | |||
| // const getNodeById = useCallback( | |||
| // (nodeId: string) => { | |||
| // const data = currentEventListWithoutMessage | |||
| // .map((x) => x.data) | |||
| // .filter((x) => x.component_id === nodeId); | |||
| // if ('begin' === nodeId) return t('flow.begin'); | |||
| // if (data && data.length) { | |||
| // return data[0]; | |||
| // } | |||
| // return {}; | |||
| // }, | |||
| // [currentEventListWithoutMessage], | |||
| // ); | |||
| const startedNodeList = useMemo(() => { | |||
| const finish = currentEventListWithoutMessage?.some( | |||
| (item) => item.event === MessageEventType.WorkflowFinished, | |||
| ); | |||
| setISStopFetchTrace(finish); | |||
| const duplicateList = currentEventListWithoutMessage?.filter( | |||
| (x) => x.event === MessageEventType.NodeStarted, | |||
| ) as INodeEvent[]; | |||
| // Remove duplicate nodes | |||
| return duplicateList?.reduce<Array<INodeEvent>>((pre, cur) => { | |||
| if (pre.every((x) => x.data.component_id !== cur.data.component_id)) { | |||
| pre.push(cur); | |||
| } | |||
| return pre; | |||
| }, []); | |||
| }, [currentEventListWithoutMessage]); | |||
| const hasTrace = useCallback( | |||
| (componentId: string) => { | |||
| if (Array.isArray(traceData)) { | |||
| return traceData?.some((x) => x.component_id === componentId); | |||
| } | |||
| return false; | |||
| }, | |||
| [traceData], | |||
| ); | |||
| const filterTrace = useCallback( | |||
| (componentId: string) => { | |||
| const trace = traceData | |||
| ?.filter((x) => x.component_id === componentId) | |||
| .reduce<ITraceData['trace']>((pre, cur) => { | |||
| pre.push(...cur.trace); | |||
| return pre; | |||
| }, []); | |||
| return Array.isArray(trace) ? trace : [{}]; | |||
| }, | |||
| [traceData], | |||
| ); | |||
| const filterFinishedNodeList = useCallback( | |||
| (componentId: string) => { | |||
| const nodeEventList = currentEventListWithoutMessage | |||
| .filter( | |||
| (x) => | |||
| x.event === MessageEventType.NodeFinished && | |||
| (x.data as INodeData)?.component_id === componentId, | |||
| ) | |||
| .map((x) => x.data); | |||
| return nodeEventList; | |||
| }, | |||
| [currentEventListWithoutMessage], | |||
| ); | |||
| return ( | |||
| <Timeline> | |||
| {startedNodeList?.map((x, idx) => { | |||
| const nodeDataList = filterFinishedNodeList(x.data.component_id); | |||
| const finishNodeIds = nodeDataList.map( | |||
| (x: INodeData) => x.component_id, | |||
| ); | |||
| const inputs = getInputsOrOutputs(nodeDataList, 'inputs'); | |||
| const outputs = getInputsOrOutputs(nodeDataList, 'outputs'); | |||
| const nodeLabel = x.data.component_type; | |||
| return ( | |||
| <> | |||
| <TimelineItem | |||
| key={idx} | |||
| step={idx} | |||
| className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-8" | |||
| > | |||
| <TimelineHeader> | |||
| <TimelineSeparator | |||
| className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5 top-6 bg-background-checked" | |||
| style={{ | |||
| background: | |||
| x.data.component_type === 'Agent' | |||
| ? 'repeating-linear-gradient( to bottom, rgba(76, 164, 231, 1), rgba(76, 164, 231, 1) 5px, transparent 5px, transparent 10px' | |||
| : '', | |||
| }} | |||
| /> | |||
| <TimelineIndicator | |||
| className={cn( | |||
| ' group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground flex size-6 p-1 items-center justify-center group-data-[orientation=vertical]/timeline:-left-7', | |||
| { | |||
| 'border border-blue-500': finishNodeIds.includes( | |||
| x.data.component_id, | |||
| ), | |||
| }, | |||
| )} | |||
| > | |||
| <div className='relative after:content-[""] after:absolute after:inset-0 after:z-10 after:bg-transparent after:transition-all after:duration-300'> | |||
| <div className="absolute inset-0 z-10 flex items-center justify-center "> | |||
| <div | |||
| className={cn('rounded-full w-6 h-6', { | |||
| ' border-muted-foreground border-2 border-t-transparent animate-spin ': | |||
| !finishNodeIds.includes(x.data.component_id), | |||
| })} | |||
| ></div> | |||
| </div> | |||
| <div className="size-6 flex items-center justify-center"> | |||
| <OperatorIcon | |||
| className="size-4" | |||
| name={nodeLabel as Operator} | |||
| ></OperatorIcon> | |||
| </div> | |||
| </div> | |||
| </TimelineIndicator> | |||
| </TimelineHeader> | |||
| <TimelineContent className="text-foreground rounded-lg border mb-5"> | |||
| <section key={idx}> | |||
| <Accordion | |||
| type="single" | |||
| collapsible | |||
| className="bg-background-card px-3" | |||
| > | |||
| <AccordionItem value={idx.toString()}> | |||
| <AccordionTrigger> | |||
| <div className="flex gap-2 items-center"> | |||
| <span>{x.data?.component_name}</span> | |||
| <span className="text-text-sub-title text-xs"> | |||
| {x.data.elapsed_time?.toString().slice(0, 6)} | |||
| </span> | |||
| <span | |||
| className={cn( | |||
| 'border-background -end-1 -top-1 size-2 rounded-full border-2 bg-dot-green', | |||
| { 'text-dot-green': x.data.error === null }, | |||
| { 'text-dot-red': x.data.error !== null }, | |||
| )} | |||
| > | |||
| <span className="sr-only">Online</span> | |||
| </span> | |||
| </div> | |||
| </AccordionTrigger> | |||
| <AccordionContent> | |||
| <div className="space-y-2"> | |||
| <JsonViewer data={inputs} title="Input"></JsonViewer> | |||
| <JsonViewer | |||
| data={outputs} | |||
| title={'Output'} | |||
| ></JsonViewer> | |||
| </div> | |||
| </AccordionContent> | |||
| </AccordionItem> | |||
| </Accordion> | |||
| </section> | |||
| </TimelineContent> | |||
| </TimelineItem> | |||
| {hasTrace(x.data.component_id) && ( | |||
| <ToolTimelineItem | |||
| tools={filterTrace(x.data.component_id)} | |||
| ></ToolTimelineItem> | |||
| )} | |||
| </> | |||
| ); | |||
| })} | |||
| </Timeline> | |||
| ); | |||
| }; | |||
| @@ -1,4 +1,5 @@ | |||
| import { SharedFrom } from '@/constants/chat'; | |||
| import { IEventList } from '@/hooks/use-send-message'; | |||
| import { useSendAgentMessage } from '@/pages/agent/chat/use-send-agent-message'; | |||
| import trim from 'lodash/trim'; | |||
| import { useSearchParams } from 'umi'; | |||
| @@ -27,14 +28,16 @@ export const useGetSharedChatSearchParams = () => { | |||
| }; | |||
| }; | |||
| export function useSendNextSharedMessage() { | |||
| export const useSendNextSharedMessage = ( | |||
| addEventList: (data: IEventList, messageId: string) => void, | |||
| ) => { | |||
| const { from, sharedId: conversationId } = useGetSharedChatSearchParams(); | |||
| const url = `/api/v1/${from === SharedFrom.Agent ? 'agentbots' : 'chatbots'}/${conversationId}/completions`; | |||
| const ret = useSendAgentMessage(url); | |||
| const ret = useSendAgentMessage(url, addEventList); | |||
| return { | |||
| ...ret, | |||
| hasError: false, | |||
| }; | |||
| } | |||
| }; | |||
| @@ -11,6 +11,7 @@ import { | |||
| } from '@/hooks/use-agent-request'; | |||
| import { cn } from '@/lib/utils'; | |||
| import i18n from '@/locales/config'; | |||
| import { useCacheChatLog } from '@/pages/agent/hooks/use-cache-chat-log'; | |||
| import { useSendButtonDisabled } from '@/pages/chat/hooks'; | |||
| import { buildMessageUuidWithRole } from '@/utils/chat'; | |||
| import React, { forwardRef, useCallback, useMemo } from 'react'; | |||
| @@ -31,7 +32,13 @@ const ChatContainer = () => { | |||
| const { uploadCanvasFile, loading } = | |||
| useUploadCanvasFileWithProgress(conversationId); | |||
| const { | |||
| addEventList, | |||
| setCurrentMessageId, | |||
| currentEventListWithoutMessageById, | |||
| clearEventList, | |||
| currentMessageId, | |||
| } = useCacheChatLog(); | |||
| const { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| @@ -43,9 +50,22 @@ const ChatContainer = () => { | |||
| stopOutputMessage, | |||
| findReferenceByMessageId, | |||
| appendUploadResponseList, | |||
| } = useSendNextSharedMessage(); | |||
| } = useSendNextSharedMessage(addEventList); | |||
| const sendDisabled = useSendButtonDisabled(value); | |||
| // useEffect(() => { | |||
| // if (derivedMessages.length) { | |||
| // const derivedMessagesFilter = derivedMessages.filter( | |||
| // (message) => message.role === MessageType.Assistant, | |||
| // ); | |||
| // if (derivedMessagesFilter.length) { | |||
| // const message = derivedMessagesFilter[derivedMessagesFilter.length - 1]; | |||
| // setCurrentMessageId(message.id); | |||
| // } | |||
| // } | |||
| // }, [derivedMessages, setCurrentMessageId]); | |||
| const useFetchAvatar = useMemo(() => { | |||
| return from === SharedFrom.Agent | |||
| ? useFetchAgentAvatar | |||
| @@ -81,6 +101,11 @@ const ChatContainer = () => { | |||
| return ( | |||
| <MessageItem | |||
| visibleAvatar={visibleAvatar} | |||
| conversationId={conversationId} | |||
| currentEventListWithoutMessageById={ | |||
| currentEventListWithoutMessageById | |||
| } | |||
| setCurrentMessageId={setCurrentMessageId} | |||
| key={buildMessageUuidWithRole(message)} | |||
| avatarDialog={avatarData.avatar} | |||
| item={message} | |||
| @@ -103,7 +128,6 @@ const ChatContainer = () => { | |||
| </div> | |||
| <div ref={ref} /> | |||
| </div> | |||
| <NextMessageInput | |||
| isShared | |||
| value={value} | |||
| @@ -1,5 +1,6 @@ | |||
| import api from '@/utils/api'; | |||
| import { registerNextServer } from '@/utils/register-server'; | |||
| import request from '@/utils/request'; | |||
| const { | |||
| getCanvasSSE, | |||
| @@ -104,4 +105,8 @@ const methods = { | |||
| const agentService = registerNextServer<keyof typeof methods>(methods); | |||
| export const fetchTrace = (data: { canvas_id: string; message_id: string }) => { | |||
| return request.get(methods.trace.url, { params: data }); | |||
| }; | |||
| export default agentService; | |||