mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
feat(agentflow): add chat panel for real-time flow testing
Add TestFlowDialog — an SSE-driven chat panel for testing agentflows. Messages stream in real time using @microsoft/fetch-event-source, with node execution status badges updating live as the flow runs. Key additions: - TestFlowDialog: streaming chat, HITL action buttons, history load - TestFlowButton container: FAB with expand/clear controls - StyledFab moved from node-palette to atoms (shared use) - Agentflow.tsx: flowId prop + isChatOpen to hide toolbar while testing - chatflows.ts: abortMessage for stopping in-progress streams - jest mocks for react-markdown, remark-gfm, uuid, and image assets - vite proxy config fixed for cross-origin dev Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
92f68d1c83
commit
43b21d346a
@@ -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
|
||||
|
||||
@@ -338,6 +338,7 @@ export function E2eExample() {
|
||||
apiBaseUrl={apiBaseUrl}
|
||||
token={token ?? undefined}
|
||||
initialFlow={loadedFlow}
|
||||
flowId={activeChatflowId}
|
||||
onFlowChange={handleFlowChange}
|
||||
onSave={handleSave}
|
||||
canvasActions={canvasActions}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ const baseConfig = {
|
||||
moduleNameMapper: {
|
||||
'\\.(css|less|scss|sass)$': '<rootDir>/src/__mocks__/styleMock.js',
|
||||
'\\.svg$': '<rootDir>/src/__mocks__/styleMock.js',
|
||||
'\\.(png|jpg|jpeg|gif|webp|ico)$': '<rootDir>/src/__mocks__/fileMock.js',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@test-utils/(.*)$': '<rootDir>/src/__test_utils__/$1',
|
||||
// TipTap + lowlight ship ESM-only — Jest (CJS) cannot import them,
|
||||
@@ -21,6 +22,11 @@ const baseConfig = {
|
||||
'^@tiptap/(.+)$': '<rootDir>/src/__mocks__/@tiptap/$1.ts',
|
||||
'^lowlight$': '<rootDir>/src/__mocks__/lowlight.ts',
|
||||
'^tippy\\.js$': '<rootDir>/src/__mocks__/tippy.js.ts',
|
||||
// uuid ships ESM-only in browser context — redirect to deterministic CJS stub
|
||||
'^uuid$': '<rootDir>/src/__mocks__/uuid.ts',
|
||||
// react-markdown and remark-gfm ship ESM-only — redirect to lightweight CJS stubs
|
||||
'^react-markdown$': '<rootDir>/src/__mocks__/react-markdown.tsx',
|
||||
'^remark-gfm$': '<rootDir>/src/__mocks__/remark-gfm.ts',
|
||||
// Bypass React.lazy wrappers — resolve Foo.lazy → Foo so tests render synchronously
|
||||
'(.*)\\.lazy$': '$1'
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
>
|
||||
<TestFlowButton />
|
||||
<ValidationFeedback
|
||||
nodes={nodes as FlowNode[]}
|
||||
edges={edges as FlowEdge[]}
|
||||
availableNodes={availableNodes}
|
||||
setNodes={setLocalNodes as React.Dispatch<React.SetStateAction<FlowNode[]>>}
|
||||
/>
|
||||
{canvasActions}
|
||||
<TestFlowButton onOpenChange={setIsChatOpen} />
|
||||
{!isChatOpen && (
|
||||
<ValidationFeedback
|
||||
nodes={nodes as FlowNode[]}
|
||||
edges={edges as FlowEdge[]}
|
||||
availableNodes={availableNodes}
|
||||
setNodes={setLocalNodes as React.Dispatch<React.SetStateAction<FlowNode[]>>}
|
||||
/>
|
||||
)}
|
||||
{!isChatOpen && canvasActions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -374,6 +387,7 @@ export const Agentflow = forwardRef<AgentFlowInstance, AgentflowProps>(function
|
||||
token,
|
||||
requestInterceptor,
|
||||
initialFlow,
|
||||
flowId,
|
||||
components,
|
||||
onFlowChange,
|
||||
onSave,
|
||||
@@ -402,6 +416,7 @@ export const Agentflow = forwardRef<AgentFlowInstance, AgentflowProps>(function
|
||||
<AgentflowCanvasWithRef
|
||||
ref={ref}
|
||||
initialFlow={initialFlow}
|
||||
flowId={flowId}
|
||||
readOnly={readOnly}
|
||||
onFlowChange={onFlowChange}
|
||||
onSave={onSave}
|
||||
@@ -425,6 +440,7 @@ const AgentflowCanvasWithRef = forwardRef<
|
||||
AgentFlowInstance,
|
||||
{
|
||||
initialFlow?: FlowData
|
||||
flowId?: string
|
||||
readOnly?: boolean
|
||||
onFlowChange?: FlowDataCallback
|
||||
onSave?: FlowDataCallback
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub'
|
||||
@@ -0,0 +1,4 @@
|
||||
import React from 'react'
|
||||
|
||||
const ReactMarkdown = ({ children }: { children?: React.ReactNode }) => <span>{children}</span>
|
||||
export default ReactMarkdown
|
||||
@@ -0,0 +1,2 @@
|
||||
const remarkGfm = () => {}
|
||||
export default remarkGfm
|
||||
@@ -0,0 +1,7 @@
|
||||
let _counter = 0
|
||||
|
||||
export const v4 = (): string => `test-uuid-${++_counter}`
|
||||
|
||||
export const reset = (): void => {
|
||||
_counter = 0
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
+2
-2
@@ -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<StyledFabProps> = styled(Fab, {
|
||||
shouldForwardProp: (prop) => prop !== 'gradient'
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<StyledFab size='small' color='secondary' aria-label='test flow' title='Test Flow' onClick={handleOpen}>
|
||||
<IconMessage />
|
||||
</StyledFab>
|
||||
|
||||
{/* TestFlowDialog will be rendered here in Step 9 */}
|
||||
{open && (
|
||||
<div data-testid='test-flow-dialog-placeholder' style={{ display: 'none' }}>
|
||||
{/* chatflowId={state.chatflow.id} onClose={handleClose} */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<HTMLSpanElement>(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 && (
|
||||
<StyledFab size='small' color='primary' aria-label='expand' title='Expand Chat'>
|
||||
<IconArrowsMaximize />
|
||||
</StyledFab>
|
||||
)}
|
||||
{open && (
|
||||
<StyledFab size='small' color='error' aria-label='clear' title='Clear Chat' onClick={handleClear}>
|
||||
<IconEraser />
|
||||
</StyledFab>
|
||||
)}
|
||||
<span ref={anchorRef} style={{ display: 'inline-flex' }}>
|
||||
<StyledFab size='small' color='secondary' aria-label='test flow' title='Test Flow' onClick={handleToggle}>
|
||||
{open ? <IconX /> : <IconMessage />}
|
||||
</StyledFab>
|
||||
</span>
|
||||
|
||||
<Popper
|
||||
placement='bottom-end'
|
||||
open={open}
|
||||
anchorEl={anchorRef.current}
|
||||
role={undefined}
|
||||
transition
|
||||
popperOptions={{
|
||||
modifiers: [{ name: 'offset', options: { offset: [40, 14] } }]
|
||||
}}
|
||||
sx={{ zIndex: 1000 }}
|
||||
>
|
||||
{({ TransitionProps }) => (
|
||||
<Fade {...TransitionProps}>
|
||||
<Paper>
|
||||
<ClickAwayListener onClickAway={handleClickAway}>
|
||||
<MainCard
|
||||
border={false}
|
||||
content={false}
|
||||
boxShadow
|
||||
shadow={theme.shadows[16]}
|
||||
sx={{ width: 400, height: 'calc(100vh - 180px)', bgcolor: 'background.paper' }}
|
||||
>
|
||||
<TestFlowDialog key={clearKey} chatflowId={chatflowId} open={open} />
|
||||
</MainCard>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Fade>
|
||||
)}
|
||||
</Popper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<TestFlowDialog chatflowId='flow-123' open={open} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
type SSEHandlers = {
|
||||
onopen: (response: Response) => Promise<void>
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<ChatMessage[]>([{ id: uuidv4(), type: 'bot', text: WELCOME_MESSAGE }])
|
||||
const [userInput, setUserInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [followUpPrompts, setFollowUpPrompts] = useState<string[]>([])
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
|
||||
const chatIdRef = useRef(uuidv4())
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(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<string, string> = { '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<string, unknown> = { 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<string, unknown>
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 1,
|
||||
justifyContent: msg.type === 'user' ? 'flex-end' : 'flex-start'
|
||||
}}
|
||||
>
|
||||
{msg.type === 'bot' && (
|
||||
<img
|
||||
src={robotPNG}
|
||||
alt='AI'
|
||||
width='30'
|
||||
height='30'
|
||||
style={{ borderRadius: '1rem', flexShrink: 0, marginBottom: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
maxWidth: '75%',
|
||||
backgroundColor: msg.error
|
||||
? theme.palette.error.light
|
||||
: msg.type === 'user'
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.action.hover,
|
||||
color: msg.error ? theme.palette.error.contrastText : msg.type === 'user' ? '#fff' : 'inherit',
|
||||
borderRadius: msg.type === 'user' ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
{msg.error ? (
|
||||
<Typography variant='body2'>Error: {msg.error}</Typography>
|
||||
) : msg.text ? (
|
||||
msg.type === 'bot' ? (
|
||||
<Typography variant='body2' component='div' sx={{ '& p': { m: 0 }, '& p + p': { mt: 1 } }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.text}</ReactMarkdown>
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant='body2'>{msg.text}</Typography>
|
||||
)
|
||||
) : loading && msg.type === 'bot' ? (
|
||||
<CircularProgress size={16} />
|
||||
) : null}
|
||||
|
||||
{msg.action?.elements && msg.action.elements.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
|
||||
{msg.action.elements.map((elem, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
size='small'
|
||||
variant='outlined'
|
||||
onClick={() => handleActionClick(elem, msg.action!)}
|
||||
>
|
||||
{elem.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
{msg.type === 'user' && (
|
||||
<img
|
||||
src={accountPNG}
|
||||
alt='Me'
|
||||
width='30'
|
||||
height='30'
|
||||
style={{ borderRadius: '1rem', flexShrink: 0, marginBottom: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{followUpPrompts.length > 0 && !loading && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, pl: 1 }}>
|
||||
{followUpPrompts.map((prompt, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={prompt}
|
||||
size='small'
|
||||
variant='outlined'
|
||||
onClick={() => {
|
||||
setFollowUpPrompts([])
|
||||
void handleSendCore(prompt)
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2, pt: 1, borderTop: `1px solid ${theme.palette.divider}` }}>
|
||||
<OutlinedInput
|
||||
inputRef={inputRef}
|
||||
fullWidth
|
||||
size='small'
|
||||
multiline
|
||||
maxRows={4}
|
||||
placeholder='Type a message...'
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={loading}
|
||||
endAdornment={
|
||||
<InputAdornment position='end'>
|
||||
{loading ? (
|
||||
<IconButton size='small' onClick={() => void handleStop()} title='Stop'>
|
||||
<IconPlayerStop size={18} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton size='small' onClick={handleSend} disabled={!userInput.trim()} edge='end'>
|
||||
<IconSend size={18} />
|
||||
</IconButton>
|
||||
)}
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Node Palette Feature - Public API
|
||||
export type { AddNodesDrawerProps } from './AddNodesDrawer'
|
||||
export { AddNodesDrawer } from './AddNodesDrawer'
|
||||
export { StyledFab } from './StyledFab'
|
||||
|
||||
@@ -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<void> => {
|
||||
await client.put(`/chatmessage/abort/${chatflowId}/${chatId}`)
|
||||
},
|
||||
|
||||
getChatModels: async (): Promise<
|
||||
Array<{
|
||||
name: string
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+9
-42
@@ -123,6 +123,9 @@ importers:
|
||||
'@emotion/styled':
|
||||
specifier: ^11.10.0
|
||||
version: 11.11.0(@emotion/[email protected](@types/[email protected])([email protected]))(@types/[email protected])([email protected])
|
||||
'@microsoft/fetch-event-source':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
'@mui/icons-material':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.3(@mui/[email protected](@emotion/[email protected](@types/[email protected])([email protected]))(@emotion/[email protected](@emotion/[email protected](@types/[email protected])([email protected]))(@types/[email protected])([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])
|
||||
@@ -189,9 +192,15 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0([email protected])
|
||||
react-markdown:
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.0(@types/[email protected])([email protected])
|
||||
reactflow:
|
||||
specifier: ^11.5.0
|
||||
version: 11.10.4(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
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/[email protected]':
|
||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
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/[email protected]':
|
||||
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/[email protected]':
|
||||
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/[email protected]':
|
||||
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/[email protected]':
|
||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||
@@ -6416,35 +6413,30 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
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/[email protected]':
|
||||
resolution: {integrity: sha512-XBKGSYcrkdiRRjl+8XvrUR3AosXU0NvF7VuqMsm7s5nRy+nt58ZMB19Jdp1RdqewLcaYnpk8zeVs/4MlLZEJxw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-fRvZZPUiBz7NztBE/2QnCS5AtqLVhXmUOPj9IHlfGEXkapgImf4W9+FSkL8cWqoAjozyUzqFmSc4zh2ooaeF6g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-Btv2WRZOcUGi8XU80XwIvzTg4U6+l6D0V6sZTrZx214nrwxw5nAi8hysaXj/mctyClWgesyuxbeLylCBNauimg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-Li0emNnwtUZdLwHjQPBxn4VWztcrw/h7mgLyHiEI5Z0MhpeFGlzaiBHpSNVOMB/xucjXTTcO+dhv469Djr16KA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-sB8+pfkYx2kvpDCfd63d5ScYT0Fz1LO6jIb2zLZvmK9ob2D8DeVqrmBDE0iDK8KlBVmsTNzrjr3G1xV4eUZhSw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-5GQ6PFhh7E6jQm70p1aW05G2cap5zMOvO0se5JMecHeAdj5ZhWEHbJ4hiKpfi1nnnEdTauDXxPgXae/mqjow9w==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-N/euLsBd1rekWcuduakTo/dJw6U6sBP3eUq+RXM9RNfPuWTvG2w/WObDkIvJ2KChy6oxZmOSC08Ak2OJA0UiAA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-2l9sA7d7QdikL0xQwNMO3xURBUNEWyHVHfAsHsUdq+E/pgLTUcCE+gih5PCdmyHmfTDeXUWVhqL0WZzg0nua3g==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-XZdD3fEEQcwG2KrJDdEQu7NrHonPxxaV0/w2HpvINBdcqebz1aL+0vM2WFJq4DeiAVT6F5SUQas65HY5JDqoPw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-7ayfgvtmmWgKWBkCGg5+xTQ0r5V1owVm67zTrsEY1008L5ro7mCyGYORomARt/OquB9KY7LpxVBZes+oSniAAQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-B+IJgcBnE2bm93jEW5kHisqvPITs4ddLOROAcOc/diBgrEiQJJ6Qcjby75rFSmH5eMGrqJryUgJDhrfj942apQ==}
|
||||
@@ -8353,28 +8334,24 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-LGQsKJ8MA9zZ8xHCkbGkcPSmpkZL2O7drvwsGKynyCttHhpwVjj9lguhD4DWU3+FWIsjvho5Vu0Ggei8OYi/Lw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-10JL2nLIreMQDKvq2TECnQe5fCuoqBHu1yW8aChqgHUyg9d7gfZX/kppUsuimqcgRBnS0AjTDAA+JF6UsG/2Yg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-EGyjFVzVY6Do89x8sfah7I3cuP4MwtwzmA6OlfD/KASqfCFf5eIaEBMbajgR41bVfMV7lK72lwAIea5xEyq1AQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-gfW9AuXvwSyK07Vb8Y8E9m2oJZk21WqcD+X4BZhkbKB0TCZK0zk1j/HpS2UFlr1JB2zPKPpSWLU3ll0GEHRG2A==}
|
||||
@@ -9468,49 +9445,41 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/[email protected]':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/[email protected]':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/[email protected]':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/[email protected]':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/[email protected]':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/[email protected]':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/[email protected]':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/[email protected]':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -10699,14 +10668,12 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RcvBcECbUcFXlFM2httdGc+3wmkI76tD9iGS0H9npEhz4LyLvdXKBK8CZtw67hsHCH09KklKw4ITkSS85pHVbA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-296SxWNwsmvP+1Ggkl72norTFLOivoXhGu0t5mXNIbd7yd2UodntvAvG2cheTHDCL/ILbox4u+6KHNvRxHgm6A==}
|
||||
|
||||
Reference in New Issue
Block a user