Bläddra i källkod

Feat: Add note node #3221 (#8728)

### What problem does this PR solve?

Feat: Add note node #3221

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
tags/v0.20.0
balibabu 3 månader sedan
förälder
incheckning
3fe143d84a
Inget konto är kopplat till bidragsgivarens mejladress

+ 58
- 0
web/package-lock.json Visa fil

@@ -70,6 +70,7 @@
"mammoth": "^1.7.2",
"next-themes": "^0.4.6",
"openai-speech-stream-player": "^1.0.8",
"pptx-preview": "^1.0.5",
"rc-tween-one": "^3.0.6",
"react-copy-to-clipboard": "^5.1.0",
"react-dropzone": "^14.3.5",
@@ -15819,6 +15820,22 @@
"node": ">=4"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
@@ -26241,6 +26258,32 @@
"resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/pptx-preview": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/pptx-preview/-/pptx-preview-1.0.5.tgz",
"integrity": "sha512-4SafvnLUpwpAY9pHHTHzzU77DANTnxZQgnLK51g3qqv0CMSOAV6f9SVc9ANYXJ0+vyTwakt780xY4s/mbRO/KQ==",
"license": "ISC",
"dependencies": {
"echarts": "^5.5.1",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"tslib": "^2.7.0",
"uuid": "^10.0.0"
}
},
"node_modules/pptx-preview/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -33925,6 +33968,21 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zustand": {
"version": "4.5.2",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.2.tgz",

+ 1
- 1
web/src/hooks/use-agent-request.ts Visa fil

@@ -318,7 +318,7 @@ export const useFetchMessageTrace = () => {
refetchOnWindowFocus: false,
gcTime: 0,
enabled: !!id && !!messageId,
refetchInterval: 2000,
refetchInterval: 3000,
queryFn: async () => {
const { data } = await flowService.trace({
canvas_id: id,

+ 39
- 3
web/src/pages/agent/canvas/index.tsx Visa fil

@@ -1,11 +1,21 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import {
Background,
ConnectionMode,
ControlButton,
Controls,
NodeTypes,
ReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useEffect } from 'react';
import { NotebookPen } from 'lucide-react';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ChatSheet } from '../chat/chat-sheet';
import {
AgentChatContext,
@@ -21,6 +31,7 @@ import {
import { useAddNode } from '../hooks/use-add-node';
import { useBeforeDelete } from '../hooks/use-before-delete';
import { useCacheChatLog } from '../hooks/use-cache-chat-log';
import { useMoveNote } from '../hooks/use-move-note';
import { useShowDrawer, useShowLogSheet } from '../hooks/use-show-drawer';
import { LogSheet } from '../log-sheet';
import RunSheet from '../run-sheet';
@@ -77,6 +88,7 @@ interface IProps {
}

function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
const { t } = useTranslation();
const {
nodes,
edges,
@@ -94,7 +106,6 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {

const {
onNodeClick,
onPaneClick,
clickedNode,
formDrawerVisible,
hideFormDrawer,
@@ -124,7 +135,17 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {

const { handleBeforeDelete } = useBeforeDelete();

const { addCanvasNode } = useAddNode(reactFlowInstance);
const { addCanvasNode, addNoteNode } = useAddNode(reactFlowInstance);

const { ref, showImage, hideImage, imgVisible, mouse } = useMoveNote();

const onPaneClick = useCallback(() => {
hideFormDrawer();
if (imgVisible) {
addNoteNode(mouse);
hideImage();
}
}, [addNoteNode, hideFormDrawer, hideImage, imgVisible, mouse]);

useEffect(() => {
if (!chatVisible) {
@@ -176,6 +197,7 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
className="h-full"
colorMode="dark"
defaultEdgeOptions={{
type: 'buttonEdge',
markerEnd: 'logo',
@@ -189,8 +211,22 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
onBeforeDelete={handleBeforeDelete}
>
<Background />
<Controls position={'bottom-center'} orientation="horizontal">
<ControlButton>
<Tooltip>
<TooltipTrigger asChild>
<NotebookPen className="!fill-none" onClick={showImage} />
</TooltipTrigger>
<TooltipContent>{t('flow.note')}</TooltipContent>
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow>
</AgentInstanceContext.Provider>
<NotebookPen
className={cn('hidden absolute size-6', { block: imgVisible })}
ref={ref}
></NotebookPen>
{formDrawerVisible && (
<AgentInstanceContext.Provider value={{ addCanvasNode }}>
<FormSheet

+ 1
- 33
web/src/pages/agent/canvas/node/iteration-node.tsx Visa fil

@@ -12,41 +12,9 @@ import { RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ResizeIcon, controlStyle } from './resize-icon';
import { ToolBar } from './toolbar';

function ResizeIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="#5025f9"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{
position: 'absolute',
right: 5,
bottom: 5,
}}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="16 20 20 20 20 16" />
<line x1="14" y1="14" x2="20" y2="20" />
<polyline points="8 4 4 4 4 8" />
<line x1="4" y1="4" x2="10" y2="10" />
</svg>
);
}

const controlStyle = {
background: 'transparent',
border: 'none',
cursor: 'nwse-resize',
};

export function InnerIterationNode({
id,
data,

+ 0
- 90
web/src/pages/agent/canvas/node/note-node.tsx Visa fil

@@ -1,90 +0,0 @@
import { NodeProps, NodeResizeControl } from '@xyflow/react';
import { Flex, Form, Input } from 'antd';
import classNames from 'classnames';
import NodeDropdown from './dropdown';

import SvgIcon from '@/components/svg-icon';
import { useTheme } from '@/components/theme-provider';
import { INoteNode } from '@/interfaces/database/flow';
import { memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useHandleNodeNameChange } from '../../hooks';
import { useHandleFormValuesChange } from '../../hooks/use-watch-form-change';
import styles from './index.less';

const { TextArea } = Input;

const controlStyle = {
background: 'transparent',
border: 'none',
};

function NoteNode({ data, id }: NodeProps<INoteNode>) {
const { t } = useTranslation();
const [form] = Form.useForm();
const { theme } = useTheme();

const { name, handleNameBlur, handleNameChange } = useHandleNodeNameChange({
id,
data,
});
const { handleValuesChange } = useHandleFormValuesChange(id);

useEffect(() => {
form.setFieldsValue(data?.form);
}, [form, data?.form]);

return (
<>
<NodeResizeControl style={controlStyle} minWidth={190} minHeight={128}>
<SvgIcon
name="resize"
width={12}
style={{
position: 'absolute',
right: 5,
bottom: 5,
cursor: 'nwse-resize',
}}
></SvgIcon>
</NodeResizeControl>
<section
className={classNames(
styles.noteNode,
theme === 'dark' ? styles.dark : '',
)}
>
<Flex
justify={'space-between'}
className={classNames('note-drag-handle')}
align="center"
gap={6}
>
<SvgIcon name="note" width={14}></SvgIcon>
<Input
value={name ?? t('flow.note')}
onBlur={handleNameBlur}
onChange={handleNameChange}
className={styles.noteName}
></Input>
<NodeDropdown id={id} label={data.label}></NodeDropdown>
</Flex>
<Form
onValuesChange={handleValuesChange}
form={form}
className={styles.noteForm}
>
<Form.Item name="text" noStyle>
<TextArea
rows={3}
placeholder={t('flow.notePlaceholder')}
className={styles.noteTextarea}
/>
</Form.Item>
</Form>
</section>
</>
);
}

export default memo(NoteNode);

+ 76
- 0
web/src/pages/agent/canvas/node/note-node/index.tsx Visa fil

@@ -0,0 +1,76 @@
import { NodeProps, NodeResizeControl } from '@xyflow/react';

import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { INoteNode } from '@/interfaces/database/flow';
import { zodResolver } from '@hookform/resolvers/zod';
import { NotebookPen } from 'lucide-react';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { NodeWrapper } from '../node-wrapper';
import { ResizeIcon, controlStyle } from '../resize-icon';
import { useChangeName, useWatchFormChange } from './use-watch-change';

const FormSchema = z.object({
text: z.string(),
});

function NoteNode({ data, id }: NodeProps<INoteNode>) {
const { t } = useTranslation();

const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: data.form,
});

const { handleChangeName } = useChangeName(id);

useWatchFormChange(id, form);

return (
<NodeWrapper className="p-0 w-full h-full flex flex-col rounded-md ">
<NodeResizeControl minWidth={190} minHeight={128} style={controlStyle}>
<ResizeIcon />
</NodeResizeControl>
<section className="px-1 py-2 flex gap-2 bg-background-highlight items-center note-drag-handle rounded-s-md">
<NotebookPen className="size-4" />
<Input
type="text"
defaultValue={data.name}
onChange={handleChangeName}
></Input>
</section>
<Form {...form}>
<form className="flex-1">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem className="h-full">
<FormControl>
<Textarea
placeholder={t('flow.notePlaceholder')}
className="resize-none rounded-none p-1 h-full overflow-auto bg-background-header-bar"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</NodeWrapper>
);
}

export default memo(NoteNode);

+ 31
- 0
web/src/pages/agent/canvas/node/note-node/use-watch-change.ts Visa fil

@@ -0,0 +1,31 @@
import useGraphStore from '@/pages/agent/store';
import { useCallback, useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';

export function useWatchFormChange(id?: string, form?: UseFormReturn<any>) {
let values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);

useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id) {
values = form?.getValues() || {};
let nextValues: any = values;

updateNodeForm(id, nextValues);
}
}, [id, updateNodeForm, values]);
}

export function useChangeName(id: string) {
const updateNodeName = useGraphStore((state) => state.updateNodeName);

const handleChangeName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateNodeName(id, e.target.value.trim());
},
[id, updateNodeName],
);

return { handleChangeName };
}

+ 32
- 0
web/src/pages/agent/canvas/node/resize-icon.tsx Visa fil

@@ -0,0 +1,32 @@
export function ResizeIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="#5025f9"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{
position: 'absolute',
right: 5,
bottom: 5,
}}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="16 20 20 20 20 16" />
<line x1="14" y1="14" x2="20" y2="20" />
<polyline points="8 4 4 4 4 8" />
<line x1="4" y1="4" x2="10" y2="10" />
</svg>
);
}

export const controlStyle = {
background: 'transparent',
border: 'none',
cursor: 'nwse-resize',
};

+ 5
- 1
web/src/pages/agent/chat/hooks.ts Visa fil

@@ -85,6 +85,10 @@ function findInputFromList(eventList: IEventList) {
};
}

export function getLatestError(eventList: IEventList) {
return get(eventList.at(-1), 'data.outputs._ERROR');
}

const useGetBeginNodePrologue = () => {
const getNode = useGraphStore((state) => state.getNode);

@@ -159,7 +163,7 @@ export const useSendNextMessage = () => {
const inputAnswer = findInputFromList(answerList);
if (answerList.length > 0) {
addNewestOneAnswer({
answer: content,
answer: content || getLatestError(answerList),
id: id,
...inputAnswer,
});

+ 1
- 1
web/src/pages/agent/debug-content/uploader.tsx Visa fil

@@ -76,7 +76,7 @@ export function FileUploadDirectUpload({
onUpload={onUpload}
onFileReject={onFileReject}
maxFiles={1}
className="w-full max-w-md"
className="w-full"
multiple={false}
>
<FileUploadDropzone>

+ 16
- 5
web/src/pages/agent/hooks/use-add-node.ts Visa fil

@@ -269,9 +269,13 @@ function useResizeIterationNode() {

return { resizeIterationNode };
}
type CanvasMouseEvent = Pick<
React.MouseEvent<HTMLElement>,
'clientX' | 'clientY'
>;

export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
const { edges, nodes, addEdge, addNode, getNode, updateNode } = useGraphStore(
const { edges, nodes, addEdge, addNode, getNode } = useGraphStore(
(state) => state,
);
const getNodeName = useGetNodeName();
@@ -290,7 +294,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
position: Position.Right,
},
) =>
(event?: React.MouseEvent<HTMLElement>) => {
(event?: CanvasMouseEvent) => {
const nodeId = params.nodeId;

const node = getNode(nodeId);
@@ -303,7 +307,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
y: event?.clientY || 0,
});

if (params.position === Position.Right) {
if (params.position === Position.Right && type !== Operator.Note) {
position = calculateNewlyBackChildPosition(nodeId, params.id);
}

@@ -420,9 +424,16 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
initializeOperatorParams,
nodes,
reactFlowInstance,
updateNode,
resizeIterationNode,
],
);

return { addCanvasNode };
const addNoteNode = useCallback(
(e: CanvasMouseEvent) => {
addCanvasNode(Operator.Note)(e);
},
[addCanvasNode],
);

return { addCanvasNode, addNoteNode };
}

+ 8
- 2
web/src/pages/agent/hooks/use-cache-chat-log.ts Visa fil

@@ -1,4 +1,8 @@
import { IEventList, MessageEventType } from '@/hooks/use-send-message';
import {
IEventList,
INodeEvent,
MessageEventType,
} from '@/hooks/use-send-message';
import { useCallback, useMemo, useState } from 'react';

export const ExcludeTypes = [
@@ -41,11 +45,13 @@ export function useCacheChatLog() {
}, []);

const currentEventListWithoutMessage = useMemo(() => {
return eventList.filter(
const list = eventList.filter(
(x) =>
x.message_id === currentMessageId &&
ExcludeTypes.every((y) => y !== x.event),
);

return list as INodeEvent[];
}, [currentMessageId, eventList]);

return {

+ 35
- 0
web/src/pages/agent/hooks/use-move-note.ts Visa fil

@@ -0,0 +1,35 @@
import { useMouse } from 'ahooks';
import { useCallback, useEffect, useRef, useState } from 'react';

export function useMoveNote() {
const ref = useRef<SVGSVGElement>(null);
const mouse = useMouse();
const [imgVisible, setImgVisible] = useState(false);

const toggleVisible = useCallback((visible: boolean) => {
setImgVisible(visible);
}, []);

const showImage = useCallback(() => {
toggleVisible(true);
}, [toggleVisible]);

const hideImage = useCallback(() => {
toggleVisible(false);
}, [toggleVisible]);

useEffect(() => {
if (ref.current) {
ref.current.style.top = `${mouse.clientY - 70}px`;
ref.current.style.left = `${mouse.clientX + 10}px`;
}
}, [mouse.clientX, mouse.clientY]);

return {
ref,
showImage,
hideImage,
mouse,
imgVisible,
};
}

+ 1
- 149
web/src/pages/agent/hooks/use-watch-form-change.ts Visa fil

@@ -1,154 +1,6 @@
import { CodeTemplateStrMap, ProgrammingLanguage } from '@/constants/agent';
import { settledModelVariableMap } from '@/constants/knowledge';
import { omit } from 'lodash';
import { useCallback, useEffect } from 'react';
import { useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import { Operator } from '../constant';
import useGraphStore from '../store';
import { buildCategorizeObjectFromList, convertToStringArray } from '../utils';

export const useHandleFormValuesChange = (
operatorName: Operator,
id?: string,
form?: UseFormReturn,
) => {
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);

const handleValuesChange = useCallback(
(changedValues: any, values: any) => {
let nextValues: any = values;
// Fixed the issue that the related form value does not change after selecting the freedom field of the model
if (
Object.keys(changedValues).length === 1 &&
'parameter' in changedValues &&
changedValues['parameter'] in settledModelVariableMap
) {
nextValues = {
...values,
...settledModelVariableMap[
changedValues['parameter'] as keyof typeof settledModelVariableMap
],
};
}
if (id) {
updateNodeForm(id, nextValues);
}
},
[updateNodeForm, id],
);

let values = useWatch({ control: form?.control });

// console.log('🚀 ~ x:', values);

useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id && form?.formState.isDirty) {
values = form?.getValues();
let nextValues: any = values;
// run(id, nextValues);

const categoryDescriptionRegex = /items\.\d+\.name/g;

if (operatorName === Operator.Categorize) {
console.log('🚀 ~ useEffect ~ values:', values);
const categoryDescription = Array.isArray(values.items)
? buildCategorizeObjectFromList(values.items)
: {};
if (categoryDescription) {
nextValues = {
...omit(values, 'items'),
category_description: categoryDescription,
};
}
} else if (operatorName === Operator.Message) {
nextValues = {
...values,
content: convertToStringArray(values.content),
};
}

updateNodeForm(id, nextValues);
}
}, [form?.formState.isDirty, id, operatorName, updateNodeForm, values]);

// useEffect(() => {
// form?.subscribe({
// formState: { values: true },
// callback: ({ values }) => {
// // console.info('subscribe', values);
// },
// });
// }, [form]);

return { handleValuesChange };

useEffect(() => {
const subscription = form?.watch((value, { name, type, values }) => {
if (id && name) {
let nextValues: any = value;

// Fixed the issue that the related form value does not change after selecting the freedom field of the model
if (
name === 'parameter' &&
value['parameter'] in settledModelVariableMap
) {
nextValues = {
...value,
...settledModelVariableMap[
value['parameter'] as keyof typeof settledModelVariableMap
],
};
}

const categoryDescriptionRegex = /items\.\d+\.name/g;
if (
operatorName === Operator.Categorize &&
categoryDescriptionRegex.test(name)
) {
nextValues = {
...omit(value, 'items'),
category_description: buildCategorizeObjectFromList(value.items),
};
}

if (
operatorName === Operator.Code &&
type === 'change' &&
name === 'lang'
) {
nextValues = {
...value,
script: CodeTemplateStrMap[value.lang as ProgrammingLanguage],
};
}

if (operatorName === Operator.Message) {
nextValues = {
...value,
content: convertToStringArray(value.content),
};
}

// Manually triggered form updates are synchronized to the canvas
if (form.formState.isDirty) {
console.log(
'🚀 ~ useEffect ~ value:',
name,
type,
values,
operatorName,
);
// run(id, nextValues);
updateNodeForm(id, nextValues);
}
}
});
return () => subscription?.unsubscribe();
}, [form, form?.watch, id, operatorName, updateNodeForm]);

return { handleValuesChange };
};

export function useWatchFormChange(id?: string, form?: UseFormReturn) {
let values = useWatch({ control: form?.control });

+ 110
- 123
web/src/pages/agent/log-sheet/index.tsx Visa fil

@@ -19,11 +19,15 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
import { useFetchMessageTrace } from '@/hooks/use-agent-request';
import { ILogEvent, MessageEventType } from '@/hooks/use-send-message';
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 { isEmpty } from 'lodash';
import { get } from 'lodash';
import { BellElectric, NotebookText } from 'lucide-react';
import { useCallback, useEffect, useMemo } from 'react';
import JsonView from 'react18-json-view';
@@ -57,25 +61,19 @@ function JsonViewer({
);
}

function concatData(
firstRecord: Record<string, any> | Array<Record<string, any>>,
nextRecord: Record<string, any> | Array<Record<string, any>>,
function getInputsOrOutputs(
nodeEventList: INodeData[],
field: 'inputs' | 'outputs',
) {
let result: Array<Record<string, any>> = [];

if (!isEmpty(firstRecord)) {
result = result.concat(firstRecord);
}
const inputsOrOutputs = nodeEventList.map((x) => get(x, field, {}));

if (!isEmpty(nextRecord)) {
result = result.concat(nextRecord);
if (inputsOrOutputs.length < 2) {
return inputsOrOutputs[0] || {};
}

return isEmpty(result) ? {} : result;
return inputsOrOutputs;
}

type EventWithIndex = { startNodeIdx: number } & ILogEvent;

export function LogSheet({
hideModal,
currentEventListWithoutMessage,
@@ -96,68 +94,58 @@ export function LogSheet({
[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) => {
return traceData
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],
);

// Look up to find the nearest start component id and concatenate the finish and log data into one
const finishedNodeList = useMemo(() => {
return currentEventListWithoutMessage.filter(
(x) =>
x.event === MessageEventType.NodeFinished ||
x.event === MessageEventType.NodeLogs,
) as ILogEvent[];
}, [currentEventListWithoutMessage]);

const nextList = useMemo(() => {
return finishedNodeList.reduce<Array<EventWithIndex>>((pre, cur) => {
const startNodeIdx = (
currentEventListWithoutMessage as Array<ILogEvent>
).findLastIndex(
(x) =>
x.data.component_id === cur.data.component_id &&
x.event === MessageEventType.NodeStarted,
);

const item = pre.find((x) => x.startNodeIdx === startNodeIdx);

const { inputs = {}, outputs = {} } = cur.data;
if (item) {
const { inputs: inputList, outputs: outputList } = item.data;

item.data = {
...item.data,
inputs: concatData(inputList, inputs),
outputs: concatData(outputList, outputs),
};
} else {
pre.push({
...cur,
startNodeIdx,
});
}

return pre;
}, []);
}, [currentEventListWithoutMessage, finishedNodeList]);
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}>
@@ -170,76 +158,75 @@ export function LogSheet({
</SheetHeader>
<section className="max-h-[82vh] overflow-auto mt-6">
<Timeline>
{nextList.map((x, idx) => (
<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">
<BellElectric className="size-5" />
{/* <img
src={item.image}
alt={item.title}
className="size-6 rounded-full"
/> */}
</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 },
{startedNodeList.map((x, idx) => {
const nodeDataList = filterFinishedNodeList(x.data.component_id);
const inputs = getInputsOrOutputs(nodeDataList, 'inputs');
const outputs = getInputsOrOutputs(nodeDataList, 'outputs');
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">
<BellElectric className="size-5" />
</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>
)}
>
<span className="sr-only">Online</span>
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<JsonViewer
data={x.data.inputs}
title="Input"
></JsonViewer>

{hasTrace(x.data.component_id) && (

<JsonViewer
data={filterTrace(x.data.component_id) ?? {}}
title={'Trace'}
data={outputs}
title={'Output'}
></JsonViewer>
)}

<JsonViewer
data={x.data.outputs}
title={'Output'}
></JsonViewer>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</section>
{/* <TimelineDate className="mt-1 mb-0">{item.date}</TimelineDate> */}
</TimelineContent>
</TimelineItem>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</section>
</TimelineContent>
</TimelineItem>
);
})}
</Timeline>
</section>
</SheetContent>

+ 1
- 0
web/src/pages/agent/operator-icon.tsx Visa fil

@@ -21,6 +21,7 @@ export const OperatorIconMap = {
[Operator.Agent]: 'agent-ai',
[Operator.UserFillUp]: 'await',
[Operator.StringTransform]: 'a-textprocessing',
[Operator.Note]: 'notebook-pen',
// [Operator.Relevant]: BranchesOutlined,
// [Operator.RewriteQuestion]: FormOutlined,
// [Operator.KeywordExtract]: KeywordIcon,

+ 1
- 1
web/src/pages/chat/utils.ts Visa fil

@@ -47,7 +47,7 @@ export const currentReg = /\[ID:(\d+)\]/g;

// To be compatible with the old index matching mode
export const replaceTextByOldReg = (text: string) => {
return text.replace(oldReg, (substring: string) => {
return text?.replace(oldReg, (substring: string) => {
return `[ID:${substring.slice(2, -2)}]`;
});
};

Laddar…
Avbryt
Spara