diff --git a/packages/agentflow/examples/src/config.ts b/packages/agentflow/examples/src/config.ts index 86161f215..a7d7d41bd 100644 --- a/packages/agentflow/examples/src/config.ts +++ b/packages/agentflow/examples/src/config.ts @@ -1,6 +1,8 @@ /** * Application configuration from environment variables */ -export const apiBaseUrl = import.meta.env.VITE_INSTANCE_URL || 'http://localhost:3000' +// When VITE_INSTANCE_URL is set, use it directly (cross-origin with API key). +// Otherwise use the dev server origin so Vite's proxy routes /api/* to the backend (no CORS, cookies work). +export const apiBaseUrl = import.meta.env.VITE_INSTANCE_URL || window.location.origin export const token = import.meta.env.VITE_API_TOKEN || undefined export const agentflowId = import.meta.env.VITE_FLOW_ID || undefined diff --git a/packages/agentflow/examples/src/demos/E2eExample.tsx b/packages/agentflow/examples/src/demos/E2eExample.tsx index 0162ae9f6..fd927949f 100644 --- a/packages/agentflow/examples/src/demos/E2eExample.tsx +++ b/packages/agentflow/examples/src/demos/E2eExample.tsx @@ -338,6 +338,7 @@ export function E2eExample() { apiBaseUrl={apiBaseUrl} token={token ?? undefined} initialFlow={loadedFlow} + flowId={activeChatflowId} onFlowChange={handleFlowChange} onSave={handleSave} canvasActions={canvasActions} diff --git a/packages/agentflow/examples/vite.config.ts b/packages/agentflow/examples/vite.config.ts index e396bbad8..bb6eb5eb6 100644 --- a/packages/agentflow/examples/vite.config.ts +++ b/packages/agentflow/examples/vite.config.ts @@ -1,26 +1,33 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' -export default defineConfig({ - plugins: [react()], - root: __dirname, - resolve: { - alias: { - // Use the source files directly for development - '@flowiseai/agentflow': path.resolve(__dirname, '../src'), - '@': path.resolve(__dirname, '../src') +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, '') + // VITE_INSTANCE_URL points at a remote instance; BACKEND_URL overrides the local dev server port. + const backendUrl = env.VITE_INSTANCE_URL || 'http://localhost:3000' + + return { + plugins: [react()], + root: __dirname, + resolve: { + alias: { + // Use the source files directly for development + '@flowiseai/agentflow': path.resolve(__dirname, '../src'), + '@': path.resolve(__dirname, '../src') + } + }, + server: { + port: 5174, + proxy: { + '/api': backendUrl + }, + watch: { + ignored: ['!**/packages/agentflow/src/**'] + } + }, + optimizeDeps: { + include: ['react', 'react-dom', '@mui/material', 'reactflow'] } - }, - server: { - port: 5174, - // Watch the parent src directory for changes - watch: { - // Include the agentflow source directory - ignored: ['!**/packages/agentflow/src/**'] - } - }, - optimizeDeps: { - include: ['react', 'react-dom', '@mui/material', 'reactflow'] } }) diff --git a/packages/agentflow/jest.config.js b/packages/agentflow/jest.config.js index c92c57959..c240d9b70 100644 --- a/packages/agentflow/jest.config.js +++ b/packages/agentflow/jest.config.js @@ -14,6 +14,7 @@ const baseConfig = { moduleNameMapper: { '\\.(css|less|scss|sass)$': '/src/__mocks__/styleMock.js', '\\.svg$': '/src/__mocks__/styleMock.js', + '\\.(png|jpg|jpeg|gif|webp|ico)$': '/src/__mocks__/fileMock.js', '^@/(.*)$': '/src/$1', '^@test-utils/(.*)$': '/src/__test_utils__/$1', // TipTap + lowlight ship ESM-only — Jest (CJS) cannot import them, @@ -21,6 +22,11 @@ const baseConfig = { '^@tiptap/(.+)$': '/src/__mocks__/@tiptap/$1.ts', '^lowlight$': '/src/__mocks__/lowlight.ts', '^tippy\\.js$': '/src/__mocks__/tippy.js.ts', + // uuid ships ESM-only in browser context — redirect to deterministic CJS stub + '^uuid$': '/src/__mocks__/uuid.ts', + // react-markdown and remark-gfm ship ESM-only — redirect to lightweight CJS stubs + '^react-markdown$': '/src/__mocks__/react-markdown.tsx', + '^remark-gfm$': '/src/__mocks__/remark-gfm.ts', // Bypass React.lazy wrappers — resolve Foo.lazy → Foo so tests render synchronously '(.*)\\.lazy$': '$1' } diff --git a/packages/agentflow/package.json b/packages/agentflow/package.json index 138c20ec6..245bb84ff 100644 --- a/packages/agentflow/package.json +++ b/packages/agentflow/package.json @@ -83,7 +83,10 @@ "@uiw/codemirror-theme-sublime": "^4.21.0", "@uiw/codemirror-theme-vscode": "^4.21.0", "@uiw/react-codemirror": "^4.21.0", + "@microsoft/fetch-event-source": "^2.0.1", "axios": "1.15.0", + "react-markdown": "^9.1.0", + "remark-gfm": "^4.0.1", "dompurify": "^3.2.6", "flowise-react-json-view": "^1.21.7", "html-react-parser": "^3.0.16", diff --git a/packages/agentflow/src/Agentflow.tsx b/packages/agentflow/src/Agentflow.tsx index 1e22e52da..b759ce8c9 100644 --- a/packages/agentflow/src/Agentflow.tsx +++ b/packages/agentflow/src/Agentflow.tsx @@ -4,6 +4,8 @@ import ReactFlow, { Background, Controls, MiniMap, ReactFlowProvider, useEdgesSt import { Alert, Snackbar } from '@mui/material' import { IconSparkles } from '@tabler/icons-react' +import { StyledFab } from '@/atoms' + import { tokens } from './core/theme' import type { AgentFlowInstance, AgentflowProps, FlowData, FlowDataCallback, FlowEdge, FlowNode } from './core/types' import { initNode, resolveNodeType } from './core/utils' @@ -22,7 +24,7 @@ import { import { ValidationFeedback } from './features/canvas/components' import { GenerateFlowDialog } from './features/generator' import { EditNodeDialog } from './features/node-editor' -import { AddNodesDrawer, StyledFab } from './features/node-palette' +import { AddNodesDrawer } from './features/node-palette' import { useAgentflowContext, useConfigContext } from './infrastructure/store' import { AgentflowProvider } from './AgentflowProvider' import { useAgentflow } from './useAgentflow' @@ -35,6 +37,7 @@ import './features/canvas/canvas.css' */ function AgentflowCanvas({ initialFlow, + flowId, readOnly, onFlowChange, onSave, @@ -47,6 +50,7 @@ function AgentflowCanvas({ renderNodePalette }: { initialFlow?: FlowData + flowId?: string readOnly?: boolean onFlowChange?: FlowDataCallback onSave?: FlowDataCallback @@ -63,6 +67,7 @@ function AgentflowCanvas({ syncNodesFromReactFlow, syncEdgesFromReactFlow, setDirty, + setChatflow, setReactFlowInstance, closeEditDialog, registerLocalStateSetters, @@ -89,6 +94,7 @@ function AgentflowCanvas({ const [nodes, setLocalNodes, onNodesChange] = useNodesState(safeInitialNodes) const [edges, setLocalEdges, onEdgesChange] = useEdgesState(safeInitialEdges) const [showGenerateDialog, setShowGenerateDialog] = useState(false) + const [isChatOpen, setIsChatOpen] = useState(false) // Constraint violation snackbar state const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({ open: false, message: '' }) @@ -139,6 +145,11 @@ function AgentflowCanvas({ return () => registerOnFlowChange(undefined) }, [registerOnFlowChange, onFlowChange]) + // Sync flowId into context so features like TestFlowButton can read state.chatflow.id + useEffect(() => { + setChatflow(flowId ? { id: flowId } : null) + }, [flowId, setChatflow]) + // Sync local ReactFlow state to context (when user interacts with canvas) useEffect(() => { syncNodesFromReactFlow(nodes as FlowNode[]) @@ -287,14 +298,16 @@ function AgentflowCanvas({ gap: 8 }} > - - >} - /> - {canvasActions} + + {!isChatOpen && ( + >} + /> + )} + {!isChatOpen && canvasActions} )} @@ -374,6 +387,7 @@ export const Agentflow = forwardRef(function token, requestInterceptor, initialFlow, + flowId, components, onFlowChange, onSave, @@ -402,6 +416,7 @@ export const Agentflow = forwardRef(function {children} +export default ReactMarkdown diff --git a/packages/agentflow/src/__mocks__/remark-gfm.ts b/packages/agentflow/src/__mocks__/remark-gfm.ts new file mode 100644 index 000000000..02424580e --- /dev/null +++ b/packages/agentflow/src/__mocks__/remark-gfm.ts @@ -0,0 +1,2 @@ +const remarkGfm = () => {} +export default remarkGfm diff --git a/packages/agentflow/src/__mocks__/uuid.ts b/packages/agentflow/src/__mocks__/uuid.ts new file mode 100644 index 000000000..a1b203006 --- /dev/null +++ b/packages/agentflow/src/__mocks__/uuid.ts @@ -0,0 +1,7 @@ +let _counter = 0 + +export const v4 = (): string => `test-uuid-${++_counter}` + +export const reset = (): void => { + _counter = 0 +} diff --git a/packages/agentflow/src/assets/images/account.png b/packages/agentflow/src/assets/images/account.png new file mode 100644 index 000000000..a758a1db9 Binary files /dev/null and b/packages/agentflow/src/assets/images/account.png differ diff --git a/packages/agentflow/src/assets/images/robot.png b/packages/agentflow/src/assets/images/robot.png new file mode 100644 index 000000000..d4fe920a9 Binary files /dev/null and b/packages/agentflow/src/assets/images/robot.png differ diff --git a/packages/agentflow/src/features/node-palette/StyledFab.tsx b/packages/agentflow/src/atoms/StyledFab.tsx similarity index 95% rename from packages/agentflow/src/features/node-palette/StyledFab.tsx rename to packages/agentflow/src/atoms/StyledFab.tsx index 63d0f201b..ffd1b9f64 100644 --- a/packages/agentflow/src/features/node-palette/StyledFab.tsx +++ b/packages/agentflow/src/atoms/StyledFab.tsx @@ -10,8 +10,8 @@ export interface StyledFabProps extends FabProps { } /** - * Styled floating action button with hover effects - * Supports gradient background for special actions like Generate + * Styled floating action button with hover effects. + * Supports gradient background for special actions like Generate. */ export const StyledFab: ComponentType = styled(Fab, { shouldForwardProp: (prop) => prop !== 'gradient' diff --git a/packages/agentflow/src/atoms/index.ts b/packages/agentflow/src/atoms/index.ts index 5bd97f057..00c09c96a 100644 --- a/packages/agentflow/src/atoms/index.ts +++ b/packages/agentflow/src/atoms/index.ts @@ -17,6 +17,7 @@ export { RichTextEditor } from './RichTextEditor.lazy' export { ScenariosInput, type ScenariosInputProps } from './ScenariosInput' export { StateKeyValueInput, type StateKeyValueInputProps } from './StateKeyValueInput' export { StructuredOutputBuilder, type StructuredOutputBuilderProps, type StructuredOutputEntry } from './StructuredOutputBuilder' +export { StyledFab, type StyledFabProps } from './StyledFab' export { SuggestionDropdown, type SuggestionDropdownProps, type SuggestionDropdownRef, type SuggestionItem } from './SuggestionDropdown' export { SwitchInput, type SwitchInputProps } from './SwitchInput' export { TooltipWithParser, type TooltipWithParserProps } from './TooltipWithParser' diff --git a/packages/agentflow/src/features/canvas/components/TestFlowButton.tsx b/packages/agentflow/src/features/canvas/components/TestFlowButton.tsx deleted file mode 100644 index 619794471..000000000 --- a/packages/agentflow/src/features/canvas/components/TestFlowButton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from 'react' - -import { IconMessage } from '@tabler/icons-react' - -import { useAgentflowContext } from '@/infrastructure/store' - -import { StyledFab } from '../../node-palette/StyledFab' - -export interface TestFlowButtonProps { - /** Called when the dialog requests to be closed (e.g. after clearing chat) */ - onDialogClose?: () => void -} - -/** - * Floating action button that opens the test/chat dialog. - * Only renders when the flow has been saved (chatflow.id is present). - */ -export function TestFlowButton({ onDialogClose }: TestFlowButtonProps) { - const { state, clearExecutionState } = useAgentflowContext() - const [open, setOpen] = useState(false) - - // Only show once the flow has been saved - if (!state.chatflow?.id) return null - - const handleOpen = () => setOpen(true) - - const handleClose = () => { - setOpen(false) - clearExecutionState() - onDialogClose?.() - } - - return ( - <> - - - - - {/* TestFlowDialog will be rendered here in Step 9 */} - {open && ( -
- {/* chatflowId={state.chatflow.id} onClose={handleClose} */} -
- )} - - ) -} diff --git a/packages/agentflow/src/features/canvas/components/index.ts b/packages/agentflow/src/features/canvas/components/index.ts index 6022dfc98..2b727dd7b 100644 --- a/packages/agentflow/src/features/canvas/components/index.ts +++ b/packages/agentflow/src/features/canvas/components/index.ts @@ -7,5 +7,4 @@ export { NodeModelConfigs } from './NodeModelConfigs' export { getMinimumNodeHeight, NodeOutputHandles } from './NodeOutputHandles' export { NodeStatusIndicator, NodeWarningIndicator } from './NodeStatusIndicator' export { NodeToolbarActions } from './NodeToolbarActions' -export { TestFlowButton } from './TestFlowButton' export { ValidationFeedback } from './ValidationFeedback' diff --git a/packages/agentflow/src/features/canvas/containers/TestFlowButton.tsx b/packages/agentflow/src/features/canvas/containers/TestFlowButton.tsx new file mode 100644 index 000000000..b304fd415 --- /dev/null +++ b/packages/agentflow/src/features/canvas/containers/TestFlowButton.tsx @@ -0,0 +1,99 @@ +import { useRef, useState } from 'react' + +import { ClickAwayListener, Fade, Paper, Popper } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { IconArrowsMaximize, IconEraser, IconMessage, IconX } from '@tabler/icons-react' + +import { MainCard, StyledFab } from '@/atoms' +import { useAgentflowContext } from '@/infrastructure/store' + +import { TestFlowDialog } from './TestFlowDialog' + +export interface TestFlowButtonProps { + onDialogClose?: () => void + onOpenChange?: (open: boolean) => void +} + +/** + * Floating action button that opens the test/chat panel. + * Only renders when the flow has been saved (chatflow.id is present). + */ +export function TestFlowButton({ onDialogClose, onOpenChange }: TestFlowButtonProps) { + const theme = useTheme() + const { state, clearExecutionState } = useAgentflowContext() + const [open, setOpen] = useState(false) + const [clearKey, setClearKey] = useState(0) + const anchorRef = useRef(null) + + const chatflowId = state.chatflow?.id + if (!chatflowId) return null + + const handleToggle = () => { + const next = !open + setOpen(next) + onOpenChange?.(next) + } + + const handleClickAway = (event: MouseEvent | TouchEvent) => { + if (anchorRef.current?.contains(event.target as Node)) return + setOpen(false) + onOpenChange?.(false) + clearExecutionState() + onDialogClose?.() + } + + const handleClear = () => { + setClearKey((prev) => prev + 1) + clearExecutionState() + } + + return ( + <> + {open && ( + + + + )} + {open && ( + + + + )} + + + {open ? : } + + + + + {({ TransitionProps }) => ( + + + + + + + + + + )} + + + ) +} diff --git a/packages/agentflow/src/features/canvas/containers/TestFlowDialog.test.tsx b/packages/agentflow/src/features/canvas/containers/TestFlowDialog.test.tsx new file mode 100644 index 000000000..ed1f902a2 --- /dev/null +++ b/packages/agentflow/src/features/canvas/containers/TestFlowDialog.test.tsx @@ -0,0 +1,380 @@ +// Import after mocks are declared +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { createTheme, ThemeProvider } from '@mui/material/styles' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' + +import { TestFlowDialog } from './TestFlowDialog' + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +jest.mock('@microsoft/fetch-event-source', () => ({ + EventStreamContentType: 'text/event-stream', + fetchEventSource: jest.fn() +})) + +const mockSetNodeExecutionStatus = jest.fn() +const mockClearExecutionState = jest.fn() +const mockAbortMessage = jest.fn().mockResolvedValue(undefined) +const mockGetAllExecutions = jest.fn().mockResolvedValue({ data: [], total: 0 }) +const mockGetChatflow = jest.fn().mockResolvedValue({ id: 'flow-123', flowData: '{"nodes":[],"edges":[]}' }) + +jest.mock('@/infrastructure/store', () => ({ + useAgentflowContext: () => ({ + state: { chatflow: { id: 'flow-123' }, executionState: null }, + setNodeExecutionStatus: mockSetNodeExecutionStatus, + clearExecutionState: mockClearExecutionState + }), + useApiContext: () => ({ + apiBaseUrl: 'http://localhost:3000', + token: 'test-token', + chatflowsApi: { + abortMessage: mockAbortMessage, + getChatflow: mockGetChatflow + }, + executionsApi: { + getAllExecutions: mockGetAllExecutions + } + }) +})) + +const mockFetchEventSource = fetchEventSource as jest.Mock + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function renderDialog(open = true) { + return render( + + + + ) +} + +type SSEHandlers = { + onopen: (response: Response) => Promise + onmessage: (ev: { data: string }) => void + onclose: () => void + onerror: (err: unknown) => void +} + +// Simulate SSE: capture handler references from the fetchEventSource call +function captureSSEHandlers() { + let capturedHandlers: SSEHandlers = { + onopen: async () => {}, + onmessage: () => {}, + onclose: () => {}, + onerror: () => { + throw new Error() + } + } + mockFetchEventSource.mockImplementation(async (_url: string, opts: SSEHandlers) => { + capturedHandlers = { + onopen: opts.onopen ?? (async () => {}), + onmessage: opts.onmessage ?? (() => {}), + onclose: opts.onclose ?? (() => {}), + onerror: + opts.onerror ?? + (() => { + throw new Error() + }) + } + }) + return () => capturedHandlers +} + +function makeEvent(event: string, data: unknown) { + return { data: JSON.stringify({ event, data }) } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks() + mockGetAllExecutions.mockResolvedValue({ data: [], total: 0 }) + mockGetChatflow.mockResolvedValue({ id: 'flow-123', flowData: '{"nodes":[],"edges":[]}' }) +}) + +describe('TestFlowDialog', () => { + describe('rendering', () => { + it('renders welcome message when open', () => { + renderDialog() + expect(screen.getByText('Hi there! How can I help?')).toBeInTheDocument() + }) + + it('renders input placeholder', () => { + renderDialog() + expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument() + }) + }) + + describe('sending a message', () => { + it('adds user message on Enter', async () => { + mockFetchEventSource.mockResolvedValue(undefined) + renderDialog() + const input = screen.getByPlaceholderText('Type a message...') + + fireEvent.change(input, { target: { value: 'Hello' } }) + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument()) + }) + + it('calls fetchEventSource with correct URL and Authorization header', async () => { + mockFetchEventSource.mockResolvedValue(undefined) + renderDialog() + const input = screen.getByPlaceholderText('Type a message...') + + fireEvent.change(input, { target: { value: 'Test' } }) + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }) + + await waitFor(() => + expect(mockFetchEventSource).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/internal-prediction/flow-123', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token' + }) + }) + ) + ) + }) + + it('skips send when input is empty', () => { + renderDialog() + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + expect(mockFetchEventSource).not.toHaveBeenCalled() + }) + + it('does not send on Shift+Enter', () => { + renderDialog() + const input = screen.getByPlaceholderText('Type a message...') + fireEvent.change(input, { target: { value: 'Hello' } }) + fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }) + expect(mockFetchEventSource).not.toHaveBeenCalled() + }) + + it('calls clearExecutionState before sending', async () => { + mockFetchEventSource.mockResolvedValue(undefined) + renderDialog() + const input = screen.getByPlaceholderText('Type a message...') + + fireEvent.change(input, { target: { value: 'Hi' } }) + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockClearExecutionState).toHaveBeenCalled()) + }) + }) + + describe('SSE event handling', () => { + it('streams bot tokens into a message', async () => { + const getHandlers = captureSSEHandlers() + renderDialog() + + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Go' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockFetchEventSource).toHaveBeenCalled()) + const { onopen, onmessage } = getHandlers() + + await act(async () => { + await onopen({ ok: true, headers: new Headers({ 'content-type': 'text/event-stream' }) } as unknown as Response) + onmessage(makeEvent('start', null)) + onmessage(makeEvent('token', 'Hello')) + onmessage(makeEvent('token', ' world')) + }) + + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('calls setNodeExecutionStatus on nextAgentFlow', async () => { + const getHandlers = captureSSEHandlers() + renderDialog() + + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Run' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockFetchEventSource).toHaveBeenCalled()) + const { onopen, onmessage } = getHandlers() + + await act(async () => { + await onopen({ ok: true, headers: new Headers({ 'content-type': 'text/event-stream' }) } as unknown as Response) + onmessage(makeEvent('nextAgentFlow', { nodeId: 'node1', status: 'INPROGRESS' })) + }) + + expect(mockSetNodeExecutionStatus).toHaveBeenCalledWith('node1', 'INPROGRESS', undefined) + }) + + it('passes error field on nextAgentFlow ERROR', async () => { + const getHandlers = captureSSEHandlers() + renderDialog() + + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Fail' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockFetchEventSource).toHaveBeenCalled()) + const { onopen, onmessage } = getHandlers() + + await act(async () => { + await onopen({ ok: true, headers: new Headers({ 'content-type': 'text/event-stream' }) } as unknown as Response) + onmessage(makeEvent('nextAgentFlow', { nodeId: 'node2', status: 'ERROR', error: 'timeout' })) + }) + + expect(mockSetNodeExecutionStatus).toHaveBeenCalledWith('node2', 'ERROR', 'timeout') + }) + + it('shows error message on SSE error event', async () => { + const getHandlers = captureSSEHandlers() + renderDialog() + + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Oops' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockFetchEventSource).toHaveBeenCalled()) + const { onopen, onmessage } = getHandlers() + + await act(async () => { + await onopen({ ok: true, headers: new Headers({ 'content-type': 'text/event-stream' }) } as unknown as Response) + onmessage(makeEvent('start', null)) + onmessage(makeEvent('error', 'Something went wrong')) + }) + + expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument() + }) + + it('updates chatId from metadata event', async () => { + const getHandlers = captureSSEHandlers() + renderDialog() + + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Meta' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockFetchEventSource).toHaveBeenCalled()) + const { onopen, onmessage, onclose } = getHandlers() + + await act(async () => { + await onopen({ ok: true, headers: new Headers({ 'content-type': 'text/event-stream' }) } as unknown as Response) + onmessage(makeEvent('metadata', { chatId: 'server-chat-id-xyz' })) + onclose() // finish the stream so loading=false and input re-enables + }) + + // Subsequent send should use the server-provided chatId + await waitFor(() => expect(screen.getByPlaceholderText('Type a message...')).not.toBeDisabled()) + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Next' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => + expect(mockFetchEventSource).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('server-chat-id-xyz') + }) + ) + ) + }) + + it('renders follow-up prompt chips from metadata event', async () => { + const getHandlers = captureSSEHandlers() + renderDialog() + + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Hi' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockFetchEventSource).toHaveBeenCalled()) + const { onopen, onmessage } = getHandlers() + + await act(async () => { + await onopen({ ok: true, headers: new Headers({ 'content-type': 'text/event-stream' }) } as unknown as Response) + onmessage(makeEvent('metadata', { followUpPrompts: JSON.stringify(['Tell me more', 'Explain further']) })) + onmessage(makeEvent('end', null)) + }) + + await waitFor(() => expect(screen.getByText('Tell me more')).toBeInTheDocument()) + expect(screen.getByText('Explain further')).toBeInTheDocument() + }) + + it('renders action buttons on action event', async () => { + const getHandlers = captureSSEHandlers() + renderDialog() + + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Approve?' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockFetchEventSource).toHaveBeenCalled()) + const { onopen, onmessage } = getHandlers() + + await act(async () => { + await onopen({ ok: true, headers: new Headers({ 'content-type': 'text/event-stream' }) } as unknown as Response) + onmessage(makeEvent('start', null)) + onmessage( + makeEvent('action', { + id: 'action-1', + elements: [ + { label: 'Approve', type: 'agentflowv2-approve' }, + { label: 'Reject', type: 'agentflowv2-reject' } + ] + }) + ) + }) + + await waitFor(() => expect(screen.getByText('Approve')).toBeInTheDocument()) + expect(screen.getByText('Reject')).toBeInTheDocument() + }) + }) + + describe('stop button', () => { + it('calls abortMessage when stop is clicked', async () => { + const getHandlers = captureSSEHandlers() + renderDialog() + + fireEvent.change(screen.getByPlaceholderText('Type a message...'), { target: { value: 'Long task' } }) + fireEvent.keyDown(screen.getByPlaceholderText('Type a message...'), { key: 'Enter', shiftKey: false }) + + await waitFor(() => expect(mockFetchEventSource).toHaveBeenCalled()) + const { onopen } = getHandlers() + await act(async () => { + await onopen({ ok: true, headers: new Headers({ 'content-type': 'text/event-stream' }) } as unknown as Response) + }) + + const stopBtn = screen.getByTitle('Stop') + await act(async () => { + fireEvent.click(stopBtn) + }) + + await waitFor(() => expect(mockAbortMessage).toHaveBeenCalledWith('flow-123', expect.any(String))) + }) + }) + + describe('chat history', () => { + it('loads and displays history from executions on open', async () => { + mockGetAllExecutions.mockResolvedValue({ + data: [ + { + id: 'exec-1', + sessionId: 'session-abc', + executionData: JSON.stringify([ + { + nodeId: 'start_0', + nodeLabel: 'Start', + data: { name: 'startAgentflow', input: { question: 'What is AI?' }, output: { question: 'What is AI?' } }, + status: 'FINISHED' + }, + { + nodeId: 'llm_0', + nodeLabel: 'LLM', + data: { name: 'llmAgentflow', output: { content: 'AI stands for Artificial Intelligence.' } }, + status: 'FINISHED' + } + ]) + } + ], + total: 1 + }) + + renderDialog() + + await waitFor(() => expect(screen.getByText('What is AI?')).toBeInTheDocument()) + expect(screen.getByText('AI stands for Artificial Intelligence.')).toBeInTheDocument() + }) + }) +}) diff --git a/packages/agentflow/src/features/canvas/containers/TestFlowDialog.tsx b/packages/agentflow/src/features/canvas/containers/TestFlowDialog.tsx new file mode 100644 index 000000000..ed535da8e --- /dev/null +++ b/packages/agentflow/src/features/canvas/containers/TestFlowDialog.tsx @@ -0,0 +1,506 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import ReactMarkdown from 'react-markdown' + +import { EventStreamContentType, fetchEventSource } from '@microsoft/fetch-event-source' +import { Box, Button, Chip, CircularProgress, IconButton, InputAdornment, OutlinedInput, Paper, Typography } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { IconPlayerStop, IconSend } from '@tabler/icons-react' +import remarkGfm from 'remark-gfm' +import { v4 as uuidv4 } from 'uuid' + +import accountPNG from '@/assets/images/account.png' +import robotPNG from '@/assets/images/robot.png' +import type { ExecutionStatus } from '@/core/types' +import { useAgentflowContext, useApiContext } from '@/infrastructure/store' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ActionElement { + label: string + type: string +} + +interface ActionPayload { + id?: string + elements?: ActionElement[] + data?: unknown +} + +interface ChatMessage { + id: string + type: 'user' | 'bot' + text: string + error?: string + action?: ActionPayload +} + +interface NextAgentFlowPayload { + nodeId: string + status: ExecutionStatus + error?: string +} + +interface ExecutionDataItem { + nodeId: string + nodeLabel: string + data: { + name?: string + input?: { question?: string; [k: string]: unknown } + output?: { content?: string; question?: string; [k: string]: unknown } + error?: string + } + status?: string +} + +export interface TestFlowDialogProps { + chatflowId: string + open: boolean +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const WELCOME_MESSAGE = 'Hi there! How can I help?' +const HISTORY_LIMIT = 10 + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function TestFlowDialog({ chatflowId, open }: TestFlowDialogProps) { + const theme = useTheme() + const { setNodeExecutionStatus, clearExecutionState } = useAgentflowContext() + const { apiBaseUrl, token, chatflowsApi, executionsApi } = useApiContext() + + const [messages, setMessages] = useState([{ id: uuidv4(), type: 'bot', text: WELCOME_MESSAGE }]) + const [userInput, setUserInput] = useState('') + const [loading, setLoading] = useState(false) + const [followUpPrompts, setFollowUpPrompts] = useState([]) + const [historyLoaded, setHistoryLoaded] = useState(false) + + const chatIdRef = useRef(uuidv4()) + const bottomRef = useRef(null) + const inputRef = useRef(null) + const abortControllerRef = useRef(null) + + const scrollToBottom = useCallback(() => { + setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50) + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages, scrollToBottom]) + + useEffect(() => { + if (!open) abortControllerRef.current?.abort() + }, [open]) + + // ── Load chat history on open ───────────────────────────────────────────── + + useEffect(() => { + if (!open || historyLoaded) return + + async function loadHistory() { + try { + const page = await executionsApi.getAllExecutions({ agentflowId: chatflowId, limit: HISTORY_LIMIT }) + const executions = page.data + if (!executions?.length) return + + // Restore the most recent session's chatId so the user continues it + const latestSessionId = executions[0]?.sessionId + if (latestSessionId) chatIdRef.current = latestSessionId + + const historyMessages: ChatMessage[] = [] + // executions arrive newest-first; reverse to show oldest first + for (const execution of [...executions].reverse()) { + let executionData: ExecutionDataItem[] = [] + try { + executionData = + typeof execution.executionData === 'string' ? (JSON.parse(execution.executionData) as ExecutionDataItem[]) : [] + } catch { + continue + } + + const startNode = executionData.find((e) => e.data?.name === 'startAgentflow') + const question = (startNode?.data?.input?.question ?? startNode?.data?.output?.question) as string | undefined + + const lastWithContent = [...executionData].reverse().find((e) => e.data?.output?.content || e.data?.output?.question) + const answer = (lastWithContent?.data?.output?.content ?? lastWithContent?.data?.output?.question) as string | undefined + + if (question) { + historyMessages.push({ id: `${execution.id}-user`, type: 'user', text: question }) + if (answer && answer !== question) { + historyMessages.push({ id: `${execution.id}-bot`, type: 'bot', text: answer }) + } + } + } + + if (historyMessages.length > 0) { + setMessages((prev) => [...prev, ...historyMessages]) + } + } catch { + // history load is best-effort + } finally { + setHistoryLoaded(true) + } + } + + void loadHistory() + }, [open, chatflowId, executionsApi, historyLoaded]) + + // ── Actions ─────────────────────────────────────────────────────────────── + + const handleStop = useCallback(async () => { + abortControllerRef.current?.abort() + setLoading(false) + try { + await chatflowsApi.abortMessage(chatflowId, chatIdRef.current) + } catch { + // best-effort server abort + } + }, [chatflowId, chatflowsApi]) + + const handleActionClick = useCallback( + (elem: ActionElement, action: ActionPayload) => { + // Clear the action buttons from the last bot message + setMessages((prev) => { + const last = prev[prev.length - 1] + if (!last || last.type !== 'bot') return prev + return [...prev.slice(0, -1), { ...last, action: undefined }] + }) + void handleSendCore(elem.label, action) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + const handleSend = useCallback(() => { + const input = userInput.trim() + if (!input || loading) return + setFollowUpPrompts([]) + void handleSendCore(input) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userInput, loading]) + + // Core SSE send — shared by handleSend, handleActionClick, follow-up prompt clicks + const handleSendCore = useCallback( + async (input: string, action?: ActionPayload) => { + setUserInput('') + setLoading(true) + clearExecutionState() + setMessages((prev) => [...prev, { id: uuidv4(), type: 'user', text: input }]) + + const controller = new AbortController() + abortControllerRef.current = controller + + const headers: Record = { 'Content-Type': 'application/json' } + if (token) { + // API key auth: server validates via Authorization header + headers['Authorization'] = `Bearer ${token}` + } else { + // Session cookie auth: server validates the JWT cookie + headers['x-request-from'] = 'internal' + } + + let botMsgAdded = false + let nonStreamHandled = false + + const ensureBotMsg = () => { + if (!botMsgAdded) { + botMsgAdded = true + setMessages((prev) => [...prev, { id: uuidv4(), type: 'bot', text: '' }]) + } + } + + const body: Record = { question: input, chatId: chatIdRef.current, streaming: true } + if (action) body['action'] = action + + try { + await fetchEventSource(`${apiBaseUrl}/api/v1/internal-prediction/${chatflowId}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + openWhenHidden: true, + + async onopen(response) { + if (!response.ok) { + const text = await response.text() + throw new Error(text || `HTTP ${response.status}`) + } + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.startsWith(EventStreamContentType)) { + // Non-streaming response: parse the whole body as a message + const text = await response.text() + let answer = text + try { + const json = JSON.parse(text) as Record + answer = (json['text'] as string) || (json['answer'] as string) || (json['content'] as string) || text + } catch { + // raw text + } + nonStreamHandled = true + ensureBotMsg() + setMessages((prev) => { + const last = prev[prev.length - 1] + if (!last || last.type !== 'bot') return prev + return [...prev.slice(0, -1), { ...last, text: answer }] + }) + setLoading(false) + controller.abort() + } + }, + + onmessage(ev) { + let payload: { event: string; data: unknown } + try { + payload = JSON.parse(ev.data) as typeof payload + } catch { + return + } + + switch (payload.event) { + case 'start': + ensureBotMsg() + break + + case 'token': + ensureBotMsg() + setMessages((prev) => { + const last = prev[prev.length - 1] + if (!last || last.type !== 'bot') return prev + return [...prev.slice(0, -1), { ...last, text: last.text + (payload.data as string) }] + }) + break + + case 'nextAgentFlow': { + const { nodeId, status, error } = payload.data as NextAgentFlowPayload + setNodeExecutionStatus(nodeId, status, error) + break + } + + case 'agentFlowEvent': { + const flowPayload = payload.data as { status?: string } + const terminalStates = ['FINISHED', 'ERROR', 'STOPPED', 'TERMINATED'] + if (flowPayload?.status && terminalStates.includes(flowPayload.status)) { + setLoading(false) + } + break + } + + case 'action': + setMessages((prev) => { + const last = prev[prev.length - 1] + if (!last || last.type !== 'bot') return prev + return [...prev.slice(0, -1), { ...last, action: payload.data as ActionPayload }] + }) + break + + case 'metadata': { + const meta = payload.data as { chatId?: string; followUpPrompts?: string; chatMessageId?: string } + if (meta?.chatId) chatIdRef.current = meta.chatId + if (meta?.followUpPrompts) { + try { + const parsed: unknown = JSON.parse(meta.followUpPrompts) + const prompts = typeof parsed === 'string' ? (JSON.parse(parsed) as string[]) : (parsed as string[]) + setFollowUpPrompts(Array.isArray(prompts) ? prompts : []) + } catch { + // ignore malformed follow-up prompts + } + } + break + } + + case 'end': + setLoading(false) + setTimeout(() => inputRef.current?.focus(), 100) + break + + case 'error': + ensureBotMsg() + setMessages((prev) => { + const last = prev[prev.length - 1] + if (!last || last.type !== 'bot') return prev + return [...prev.slice(0, -1), { ...last, error: String(payload.data) }] + }) + setLoading(false) + break + + case 'abort': + setLoading(false) + break + } + scrollToBottom() + }, + + onclose() { + setLoading(false) + }, + + onerror(err) { + if (nonStreamHandled || err?.name === 'AbortError') return + ensureBotMsg() + setMessages((prev) => { + const last = prev[prev.length - 1] + if (!last || last.type !== 'bot') return prev + return [...prev.slice(0, -1), { ...last, error: (err as Error)?.message || 'Connection error' }] + }) + setLoading(false) + throw err // prevent fetchEventSource from retrying + } + }) + } catch (err) { + if ((err as Error)?.name !== 'AbortError') { + setLoading(false) + } + } + }, + [apiBaseUrl, chatflowId, token, setNodeExecutionStatus, clearExecutionState, scrollToBottom] + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && userInput.trim()) { + e.preventDefault() + handleSend() + } + } + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + + + {messages.map((msg) => ( + + {msg.type === 'bot' && ( + AI + )} + + {msg.error ? ( + Error: {msg.error} + ) : msg.text ? ( + msg.type === 'bot' ? ( + + {msg.text} + + ) : ( + {msg.text} + ) + ) : loading && msg.type === 'bot' ? ( + + ) : null} + + {msg.action?.elements && msg.action.elements.length > 0 && ( + + {msg.action.elements.map((elem, i) => ( + + ))} + + )} + + {msg.type === 'user' && ( + Me + )} + + ))} + + {followUpPrompts.length > 0 && !loading && ( + + {followUpPrompts.map((prompt, i) => ( + { + setFollowUpPrompts([]) + void handleSendCore(prompt) + }} + sx={{ cursor: 'pointer' }} + /> + ))} + + )} + +
+ + + + setUserInput(e.target.value)} + onKeyDown={handleKeyDown} + disabled={loading} + endAdornment={ + + {loading ? ( + void handleStop()} title='Stop'> + + + ) : ( + + + + )} + + } + /> + + + ) +} diff --git a/packages/agentflow/src/features/canvas/containers/index.ts b/packages/agentflow/src/features/canvas/containers/index.ts index 2e08d5840..b57f675a1 100644 --- a/packages/agentflow/src/features/canvas/containers/index.ts +++ b/packages/agentflow/src/features/canvas/containers/index.ts @@ -3,3 +3,7 @@ export { AgentFlowNode } from './AgentFlowNode' export { IterationNode } from './IterationNode' export { NodeInfoDialog } from './NodeInfoDialog' export { StickyNote } from './StickyNote' +export type { TestFlowButtonProps } from './TestFlowButton' +export { TestFlowButton } from './TestFlowButton' +export type { TestFlowDialogProps } from './TestFlowDialog' +export { TestFlowDialog } from './TestFlowDialog' diff --git a/packages/agentflow/src/features/canvas/index.ts b/packages/agentflow/src/features/canvas/index.ts index d8d3f59cb..57d25214a 100644 --- a/packages/agentflow/src/features/canvas/index.ts +++ b/packages/agentflow/src/features/canvas/index.ts @@ -1,12 +1,12 @@ // Canvas Feature - Public API // Container components (with state/logic) -import { AgentFlowEdge, AgentFlowNode, IterationNode, StickyNote } from './containers' +import { AgentFlowEdge, AgentFlowNode, IterationNode, StickyNote, TestFlowButton } from './containers' import './canvas.css' // Presentational components export type { AgentflowHeaderProps } from './components' -export { AgentflowHeader, ConnectionLine, createHeaderProps, TestFlowButton } from './components' +export { AgentflowHeader, ConnectionLine, createHeaderProps } from './components' // Hooks export { useDragAndDrop, useFlowHandlers, useFlowNodes } from './hooks' @@ -23,7 +23,9 @@ export const edgeTypes = { } // Re-export container components -export { AgentFlowEdge, AgentFlowNode, IterationNode, StickyNote } +export { AgentFlowEdge, AgentFlowNode, IterationNode, StickyNote, TestFlowButton } +export type { TestFlowButtonProps, TestFlowDialogProps } from './containers' +export { TestFlowDialog } from './containers' // Utilities and styled components export { getBuiltInAnthropicToolIcon, getBuiltInGeminiToolIcon, getBuiltInOpenAIToolIcon, renderNodeIcon } from './nodeIcons' diff --git a/packages/agentflow/src/features/node-palette/AddNodesDrawer.tsx b/packages/agentflow/src/features/node-palette/AddNodesDrawer.tsx index 71d864e21..2021edc85 100644 --- a/packages/agentflow/src/features/node-palette/AddNodesDrawer.tsx +++ b/packages/agentflow/src/features/node-palette/AddNodesDrawer.tsx @@ -20,14 +20,13 @@ import { import { useTheme } from '@mui/material/styles' import { IconMinus, IconPlus, IconSearch, IconX } from '@tabler/icons-react' -import { MainCard } from '@/atoms' +import { MainCard, StyledFab } from '@/atoms' import { tokens } from '@/core/theme/tokens' import type { NodeDataSchema } from '@/core/types' import { useApiContext } from '@/infrastructure/store' import { NodeListItem } from './NodeListItem' import { debounce, groupNodesByCategory, searchNodes } from './search' -import { StyledFab } from './StyledFab' import { useDrawerMaxHeight } from './useDrawerMaxHeight' export interface AddNodesDrawerProps { diff --git a/packages/agentflow/src/features/node-palette/index.ts b/packages/agentflow/src/features/node-palette/index.ts index 3e958e7a5..e66d27e66 100644 --- a/packages/agentflow/src/features/node-palette/index.ts +++ b/packages/agentflow/src/features/node-palette/index.ts @@ -1,4 +1,3 @@ // Node Palette Feature - Public API export type { AddNodesDrawerProps } from './AddNodesDrawer' export { AddNodesDrawer } from './AddNodesDrawer' -export { StyledFab } from './StyledFab' diff --git a/packages/agentflow/src/infrastructure/api/chatflows.ts b/packages/agentflow/src/infrastructure/api/chatflows.ts index cea03da5f..4582cb0fb 100644 --- a/packages/agentflow/src/infrastructure/api/chatflows.ts +++ b/packages/agentflow/src/infrastructure/api/chatflows.ts @@ -83,6 +83,13 @@ export function bindChatflowsApi(client: AxiosInstance) { /** * Get available chat models for generation */ + /** + * Abort an in-progress prediction for a chatflow session + */ + abortMessage: async (chatflowId: string, chatId: string): Promise => { + await client.put(`/chatmessage/abort/${chatflowId}/${chatId}`) + }, + getChatModels: async (): Promise< Array<{ name: string diff --git a/packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts b/packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts index cd7bc910c..77583d029 100644 --- a/packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts +++ b/packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts @@ -9,7 +9,8 @@ const mockApis: ApiServices = { updateChatflow: jest.fn(), deleteChatflow: jest.fn(), generateAgentflow: jest.fn(), - getChatModels: jest.fn() + getChatModels: jest.fn(), + abortMessage: jest.fn() }, chatModelsApi: { getChatModels: jest.fn() diff --git a/packages/agentflow/src/infrastructure/store/ApiContext.tsx b/packages/agentflow/src/infrastructure/store/ApiContext.tsx index d10a9b991..689a73146 100644 --- a/packages/agentflow/src/infrastructure/store/ApiContext.tsx +++ b/packages/agentflow/src/infrastructure/store/ApiContext.tsx @@ -27,6 +27,7 @@ import { interface ApiContextValue { client: AxiosInstance apiBaseUrl: string + token?: string nodesApi: NodesApi chatflowsApi: ChatflowsApi chatModelsApi: ChatModelsApi @@ -67,6 +68,7 @@ export function ApiProvider({ apiBaseUrl, token, requestInterceptor, children }: return { client, apiBaseUrl, + token, nodesApi, chatflowsApi, chatModelsApi, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd2596819..bed048793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: '@emotion/styled': specifier: ^11.10.0 version: 11.11.0(@emotion/react@11.11.4(@types/react@18.2.65)(react@18.2.0))(@types/react@18.2.65)(react@18.2.0) + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 '@mui/icons-material': specifier: ^5.0.0 version: 5.0.3(@mui/material@5.15.0(@emotion/react@11.11.4(@types/react@18.2.65)(react@18.2.0))(@emotion/styled@11.11.0(@emotion/react@11.11.4(@types/react@18.2.65)(react@18.2.0))(@types/react@18.2.65)(react@18.2.0))(@types/react@18.2.65)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@types/react@18.2.65)(react@18.2.0) @@ -189,9 +192,15 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-markdown: + specifier: ^9.1.0 + version: 9.1.0(@types/react@18.2.65)(react@18.2.0) reactflow: specifier: ^11.5.0 version: 11.10.4(@types/react@18.2.65)(immer@10.1.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -4538,79 +4547,67 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -6416,35 +6413,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.73': resolution: {integrity: sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.73': resolution: {integrity: sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.73': resolution: {integrity: sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.73': resolution: {integrity: sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.73': resolution: {integrity: sha512-YQmHXBufFBdWqhx+ympeTPkMfs3RNxaOgWm59vyjpsub7Us07BwCcmu1N5kildhO8Fm0syoI2kHnzGkJBLSvsg==} @@ -7309,67 +7301,56 @@ packages: resolution: {integrity: sha512-hLrmRl53prCcD+YXTfNvXd776HTxNh8wPAMllusQ+amcQmtgo3V5i/nkhPN6FakW+QVLoUUr2AsbtIRPFU3xIA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.45.0': resolution: {integrity: sha512-XBKGSYcrkdiRRjl+8XvrUR3AosXU0NvF7VuqMsm7s5nRy+nt58ZMB19Jdp1RdqewLcaYnpk8zeVs/4MlLZEJxw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.45.0': resolution: {integrity: sha512-fRvZZPUiBz7NztBE/2QnCS5AtqLVhXmUOPj9IHlfGEXkapgImf4W9+FSkL8cWqoAjozyUzqFmSc4zh2ooaeF6g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.45.0': resolution: {integrity: sha512-Btv2WRZOcUGi8XU80XwIvzTg4U6+l6D0V6sZTrZx214nrwxw5nAi8hysaXj/mctyClWgesyuxbeLylCBNauimg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.45.0': resolution: {integrity: sha512-Li0emNnwtUZdLwHjQPBxn4VWztcrw/h7mgLyHiEI5Z0MhpeFGlzaiBHpSNVOMB/xucjXTTcO+dhv469Djr16KA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.45.0': resolution: {integrity: sha512-sB8+pfkYx2kvpDCfd63d5ScYT0Fz1LO6jIb2zLZvmK9ob2D8DeVqrmBDE0iDK8KlBVmsTNzrjr3G1xV4eUZhSw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.45.0': resolution: {integrity: sha512-5GQ6PFhh7E6jQm70p1aW05G2cap5zMOvO0se5JMecHeAdj5ZhWEHbJ4hiKpfi1nnnEdTauDXxPgXae/mqjow9w==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.45.0': resolution: {integrity: sha512-N/euLsBd1rekWcuduakTo/dJw6U6sBP3eUq+RXM9RNfPuWTvG2w/WObDkIvJ2KChy6oxZmOSC08Ak2OJA0UiAA==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.45.0': resolution: {integrity: sha512-2l9sA7d7QdikL0xQwNMO3xURBUNEWyHVHfAsHsUdq+E/pgLTUcCE+gih5PCdmyHmfTDeXUWVhqL0WZzg0nua3g==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.45.0': resolution: {integrity: sha512-XZdD3fEEQcwG2KrJDdEQu7NrHonPxxaV0/w2HpvINBdcqebz1aL+0vM2WFJq4DeiAVT6F5SUQas65HY5JDqoPw==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.45.0': resolution: {integrity: sha512-7ayfgvtmmWgKWBkCGg5+xTQ0r5V1owVm67zTrsEY1008L5ro7mCyGYORomARt/OquB9KY7LpxVBZes+oSniAAQ==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.45.0': resolution: {integrity: sha512-B+IJgcBnE2bm93jEW5kHisqvPITs4ddLOROAcOc/diBgrEiQJJ6Qcjby75rFSmH5eMGrqJryUgJDhrfj942apQ==} @@ -8353,28 +8334,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.4.6': resolution: {integrity: sha512-LGQsKJ8MA9zZ8xHCkbGkcPSmpkZL2O7drvwsGKynyCttHhpwVjj9lguhD4DWU3+FWIsjvho5Vu0Ggei8OYi/Lw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.4.6': resolution: {integrity: sha512-10JL2nLIreMQDKvq2TECnQe5fCuoqBHu1yW8aChqgHUyg9d7gfZX/kppUsuimqcgRBnS0AjTDAA+JF6UsG/2Yg==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.4.6': resolution: {integrity: sha512-EGyjFVzVY6Do89x8sfah7I3cuP4MwtwzmA6OlfD/KASqfCFf5eIaEBMbajgR41bVfMV7lK72lwAIea5xEyq1AQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.4.6': resolution: {integrity: sha512-gfW9AuXvwSyK07Vb8Y8E9m2oJZk21WqcD+X4BZhkbKB0TCZK0zk1j/HpS2UFlr1JB2zPKPpSWLU3ll0GEHRG2A==} @@ -9468,49 +9445,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -10699,14 +10668,12 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] chromadb-js-bindings-linux-x64-gnu@1.1.1: resolution: {integrity: sha512-RcvBcECbUcFXlFM2httdGc+3wmkI76tD9iGS0H9npEhz4LyLvdXKBK8CZtw67hsHCH09KklKw4ITkSS85pHVbA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] chromadb-js-bindings-win32-x64-msvc@1.1.1: resolution: {integrity: sha512-296SxWNwsmvP+1Ggkl72norTFLOivoXhGu0t5mXNIbd7yd2UodntvAvG2cheTHDCL/ILbox4u+6KHNvRxHgm6A==}