mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
Feat/agentflow add support for Variable System & Syntax Support (#6067)
* Initial flow * Add tiptap - mention dependency similar to v2 * Add functionalities similiar to V2 * update to tiptap to 3.20.4 * Fix lint error * Added missiing test cases for new files * Styling changes * InputMessage should be visible on initial load * Fix gemini comment * Remove outdated SelectVariable * Add icon support similar to v2 * Fix gemini comments * Remove input folder and utils folder under atoms * Fix test error * Fix initlalDefualts not being set * Fix after merge with main * Fixes for review comments * Fix review comments * New test cases added for fixes * Remove additional space
This commit is contained in:
@@ -20,6 +20,7 @@ const baseConfig = {
|
||||
// so we redirect to lightweight CJS stubs under src/__mocks__/.
|
||||
'^@tiptap/(.+)$': '<rootDir>/src/__mocks__/@tiptap/$1.ts',
|
||||
'^lowlight$': '<rootDir>/src/__mocks__/lowlight.ts',
|
||||
'^tippy\\.js$': '<rootDir>/src/__mocks__/tippy.js.ts',
|
||||
// Bypass React.lazy wrappers — resolve Foo.lazy → Foo so tests render synchronously
|
||||
'(.*)\\.lazy$': '$1'
|
||||
}
|
||||
|
||||
@@ -72,10 +72,13 @@
|
||||
"@codemirror/lang-json": "^6.0.0",
|
||||
"@codemirror/lang-python": "^6.1.0",
|
||||
"@tabler/icons-react": "^3.7.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.4.3",
|
||||
"@tiptap/extension-placeholder": "^2.11.5",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@tiptap/core": "^3.20.4",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.20.4",
|
||||
"@tiptap/extension-mention": "^3.20.4",
|
||||
"@tiptap/extension-placeholder": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/react": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"@uiw/codemirror-theme-sublime": "^4.21.0",
|
||||
"@uiw/codemirror-theme-vscode": "^4.21.0",
|
||||
"@uiw/react-codemirror": "^4.21.0",
|
||||
@@ -85,6 +88,7 @@
|
||||
"html-react-parser": "^3.0.16",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^3.3.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const mergeAttributes = jest.fn((...attrs: Record<string, unknown>[]) => Object.assign({}, ...attrs))
|
||||
|
||||
export class PasteRule {
|
||||
find: unknown
|
||||
handler: unknown
|
||||
constructor(config: Record<string, unknown>) {
|
||||
this.find = config.find
|
||||
this.handler = config.handler
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const Mention = {
|
||||
extend: jest.fn(() => ({
|
||||
configure: jest.fn(() => 'CustomMention')
|
||||
})),
|
||||
configure: jest.fn(() => 'Mention')
|
||||
}
|
||||
export default Mention
|
||||
@@ -0,0 +1 @@
|
||||
export const Markdown = { configure: jest.fn(() => 'Markdown') }
|
||||
@@ -0,0 +1,4 @@
|
||||
export const model = {}
|
||||
export const state = {}
|
||||
export const view = {}
|
||||
export const transform = {}
|
||||
@@ -2,6 +2,7 @@ import { createElement, forwardRef } from 'react'
|
||||
|
||||
export const useEditor = (config?: Record<string, unknown>) => ({
|
||||
getHTML: () => (config?.content as string) ?? '<p></p>',
|
||||
getMarkdown: () => (config?.content as string) ?? '',
|
||||
setEditable: jest.fn(),
|
||||
commands: { focus: jest.fn(), setContent: jest.fn() },
|
||||
_onUpdate: config?.onUpdate
|
||||
@@ -11,3 +12,10 @@ export const EditorContent = forwardRef<HTMLDivElement, { editor?: unknown; [k:
|
||||
createElement('div', { ref, 'data-testid': 'tiptap-editor-content', 'data-has-editor': !!editor, ...rest })
|
||||
)
|
||||
EditorContent.displayName = 'EditorContent'
|
||||
|
||||
export const ReactRenderer = jest.fn().mockImplementation(() => ({
|
||||
element: document.createElement('div'),
|
||||
ref: null,
|
||||
updateProps: jest.fn(),
|
||||
destroy: jest.fn()
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
const tippy = jest.fn(() => [
|
||||
{
|
||||
setProps: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
hide: jest.fn(),
|
||||
show: jest.fn()
|
||||
}
|
||||
])
|
||||
export default tippy
|
||||
@@ -4,8 +4,8 @@ import { FormControl, Popover } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import ReactJson from 'flowise-react-json-view'
|
||||
|
||||
import type { VariableItem } from './SelectVariable'
|
||||
import { SelectVariable } from './SelectVariable'
|
||||
import type { VariableItem } from './VariablePicker'
|
||||
import { VariablePicker } from './VariablePicker'
|
||||
|
||||
export interface JsonInputProps {
|
||||
value: string
|
||||
@@ -153,7 +153,7 @@ export function JsonInput({ value, onChange, disabled = false, variableItems }:
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
slotProps={{ paper: { sx: { width: 320, maxHeight: 400 } } }}
|
||||
>
|
||||
<SelectVariable
|
||||
<VariablePicker
|
||||
items={variableItems!}
|
||||
onSelect={(val) => {
|
||||
setNewVal(val)
|
||||
|
||||
@@ -36,6 +36,12 @@ jest.mock('./RichTextEditor.lazy', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
jest.mock('./VariableInput', () => ({
|
||||
VariableInput: ({ suggestionItems }: { suggestionItems?: { id: string }[] }) => (
|
||||
<div data-testid='variable-input' data-suggestion-ids={JSON.stringify(suggestionItems?.map((i) => i.id))} />
|
||||
)
|
||||
}))
|
||||
|
||||
jest.mock('@tabler/icons-react', () => ({
|
||||
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
|
||||
IconInfoCircle: () => <span data-testid='icon-info-circle' />,
|
||||
@@ -328,8 +334,8 @@ jest.mock('./JsonInput', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
jest.mock('./SelectVariable', () => ({
|
||||
SelectVariable: ({ items, onSelect }: { items: Array<{ value: string }>; onSelect: (v: string) => void }) => (
|
||||
jest.mock('./VariablePicker', () => ({
|
||||
VariablePicker: ({ items, onSelect }: { items: Array<{ value: string }>; onSelect: (v: string) => void }) => (
|
||||
<div data-testid='select-variable'>
|
||||
{items.map((item, i) => (
|
||||
<button key={i} data-testid={`var-${item.value}`} onClick={() => onSelect(item.value)}>
|
||||
@@ -503,6 +509,28 @@ describe('NodeInputHandler – variable popover', () => {
|
||||
|
||||
expect(screen.queryByTestId('icon-variable')).toBeNull()
|
||||
})
|
||||
|
||||
it('deduplicates suggestionItem ids when variableItems share the same value', () => {
|
||||
// Two flow-state entries with the same key produce the same base id.
|
||||
// The first should keep its id; subsequent duplicates get a __N suffix.
|
||||
const variableItems = [
|
||||
{ label: '$flow.state.myVar', value: '$flow.state.myVar', category: 'Flow State' },
|
||||
{ label: '$flow.state.myVar', value: '$flow.state.myVar', category: 'Flow State' },
|
||||
{ label: '$flow.state.other', value: '$flow.state.other', category: 'Flow State' }
|
||||
]
|
||||
render(
|
||||
<NodeInputHandler
|
||||
inputParam={makeParam({ type: 'string', acceptVariable: true })}
|
||||
data={baseNodeData}
|
||||
isAdditionalParams
|
||||
onDataChange={mockOnDataChange}
|
||||
variableItems={variableItems}
|
||||
/>
|
||||
)
|
||||
|
||||
const ids = JSON.parse(screen.getByTestId('variable-input').getAttribute('data-suggestion-ids')!)
|
||||
expect(ids).toEqual(['$flow.state.myVar', '$flow.state.myVar__1', '$flow.state.other'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeInputHandler – credential type rendering', () => {
|
||||
|
||||
@@ -27,10 +27,12 @@ import { Dropdown } from './Dropdown'
|
||||
import { ExpandTextDialog } from './ExpandTextDialog'
|
||||
import { JsonInput } from './JsonInput'
|
||||
import { RichTextEditor } from './RichTextEditor.lazy'
|
||||
import type { VariableItem } from './SelectVariable'
|
||||
import { SelectVariable } from './SelectVariable'
|
||||
import { SuggestionItem } from './SuggestionDropdown'
|
||||
import { SwitchInput } from './SwitchInput'
|
||||
import { TooltipWithParser } from './TooltipWithParser'
|
||||
import { VariableInput } from './VariableInput'
|
||||
import type { VariableItem } from './VariablePicker'
|
||||
import { VariablePicker } from './VariablePicker'
|
||||
|
||||
const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => <Tooltip {...props} classes={{ popper: className }} />)({
|
||||
[`& .${tooltipClasses.tooltip}`]: {
|
||||
@@ -86,7 +88,7 @@ export interface NodeInputHandlerProps {
|
||||
arrayIndex?: number | null
|
||||
/** For array-based configs: the parent array InputParam definition. */
|
||||
parentArrayParam?: InputParam | null
|
||||
/** Variable items for the SelectVariable popover (injected from features layer). */
|
||||
/** Variable items for the VariablePicker popover (injected from features layer). */
|
||||
variableItems?: VariableItem[]
|
||||
}
|
||||
|
||||
@@ -178,6 +180,25 @@ export function NodeInputHandler({
|
||||
['string', 'password', 'code'].includes(inputParam?.type ?? '')
|
||||
)
|
||||
|
||||
// Map VariableItem[] to SuggestionItem[] for the inline autocomplete.
|
||||
// ids must be unique for correct findIndex lookups and React keys — append
|
||||
// a counter suffix when the same base id appears more than once.
|
||||
const suggestionItems: SuggestionItem[] | undefined = useMemo(() => {
|
||||
if (!inputParam?.acceptVariable || !variableItems || variableItems.length === 0) return undefined
|
||||
const idCount = new Map<string, number>()
|
||||
return variableItems.map((v) => {
|
||||
const baseId = v.value.replace(/{{|}}/g, '')
|
||||
const count = idCount.get(baseId) ?? 0
|
||||
idCount.set(baseId, count + 1)
|
||||
return {
|
||||
id: count === 0 ? baseId : `${baseId}__${count}`,
|
||||
label: v.label,
|
||||
description: v.description,
|
||||
category: v.category
|
||||
}
|
||||
})
|
||||
}, [inputParam?.acceptVariable, variableItems])
|
||||
|
||||
const renderInput = () => {
|
||||
if (!inputParam) return null
|
||||
|
||||
@@ -185,6 +206,20 @@ export function NodeInputHandler({
|
||||
|
||||
switch (inputParam.type) {
|
||||
case 'string':
|
||||
// When acceptVariable is enabled and suggestions are available, use VariableInput
|
||||
// which provides inline {{ autocomplete
|
||||
if (suggestionItems && suggestionItems.length > 0) {
|
||||
return (
|
||||
<VariableInput
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(v) => handleDataChange(v)}
|
||||
placeholder={inputParam.placeholder}
|
||||
disabled={disabled}
|
||||
rows={inputParam.rows}
|
||||
suggestionItems={suggestionItems}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (isExpandable) {
|
||||
return (
|
||||
<RichTextEditor
|
||||
@@ -444,12 +479,15 @@ export function NodeInputHandler({
|
||||
</Typography>
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
{showVariableButton && (
|
||||
<Tooltip title='Select variable'>
|
||||
<Tooltip title={suggestionItems?.length ? 'Type {{ to select variables' : 'Select variable'}>
|
||||
<IconButton
|
||||
size='small'
|
||||
sx={{ height: 25, width: 25 }}
|
||||
disabled={disabled}
|
||||
onClick={(e) => setVariableAnchorEl(e.currentTarget)}
|
||||
onClick={(e) =>
|
||||
(inputParam?.type !== 'string' || !suggestionItems?.length) &&
|
||||
setVariableAnchorEl(e.currentTarget)
|
||||
}
|
||||
>
|
||||
<IconVariable size={20} style={{ color: theme.palette.info.main }} />
|
||||
</IconButton>
|
||||
@@ -500,7 +538,7 @@ export function NodeInputHandler({
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
slotProps={{ paper: { sx: { width: 320, maxHeight: 400 } } }}
|
||||
>
|
||||
<SelectVariable items={variableItems!} onSelect={handleVariableSelect} />
|
||||
<VariablePicker items={variableItems!} onSelect={handleVariableSelect} />
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,18 +37,9 @@ export interface RichTextEditorProps {
|
||||
|
||||
/* ── TipTap extensions (no mention/variable support — that belongs in features/) ── */
|
||||
|
||||
// Use .extend() to set enableTabIndentation/tabSize because the v2 types from
|
||||
// @tiptap/extension-code-block (peer dep) don't include these v3-only options.
|
||||
// See: https://github.com/ueberdosis/tiptap/issues/7613
|
||||
const CustomCodeBlock = CodeBlockLowlight.extend({
|
||||
addOptions() {
|
||||
return { ...this.parent?.(), lowlight, enableTabIndentation: true, tabSize: 2 }
|
||||
}
|
||||
})
|
||||
|
||||
const buildExtensions = (placeholder?: string) => [
|
||||
StarterKit.configure({ codeBlock: false }),
|
||||
CustomCodeBlock,
|
||||
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }),
|
||||
...(placeholder ? [Placeholder.configure({ placeholder })] : [])
|
||||
]
|
||||
|
||||
@@ -168,7 +159,7 @@ export function RichTextEditor({ value, onChange, placeholder, disabled = false,
|
||||
// Sync external value changes into the editor (e.g. when parent state updates)
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value, false)
|
||||
editor.commands.setContent(value, { emitUpdate: false })
|
||||
}
|
||||
}, [editor, value])
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import type { VariableItem } from './SelectVariable'
|
||||
import { SelectVariable } from './SelectVariable'
|
||||
|
||||
jest.mock('@tabler/icons-react', () => ({
|
||||
IconBinaryTree: () => <span data-testid='icon-tree' />,
|
||||
IconHistory: () => <span data-testid='icon-history' />,
|
||||
IconMessageChatbot: () => <span data-testid='icon-message' />,
|
||||
IconPaperclip: () => <span data-testid='icon-paperclip' />
|
||||
}))
|
||||
|
||||
const mockItems: VariableItem[] = [
|
||||
{ label: 'question', description: "User's question", category: 'Chat Context', value: '{{question}}' },
|
||||
{ label: 'chat_history', description: 'Past history', category: 'Chat Context', value: '{{chat_history}}' },
|
||||
{ label: 'Start', description: 'Output from Start', category: 'Node Outputs', value: '{{start_0.data.instance}}' }
|
||||
]
|
||||
|
||||
describe('SelectVariable', () => {
|
||||
it('renders the title', () => {
|
||||
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText('Select Variable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all items', () => {
|
||||
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText('question')).toBeInTheDocument()
|
||||
expect(screen.getByText('chat_history')).toBeInTheDocument()
|
||||
expect(screen.getByText('Start')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders descriptions', () => {
|
||||
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText("User's question")).toBeInTheDocument()
|
||||
expect(screen.getByText('Output from Start')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with the item value when clicked', () => {
|
||||
const onSelect = jest.fn()
|
||||
render(<SelectVariable items={mockItems} onSelect={onSelect} />)
|
||||
|
||||
fireEvent.click(screen.getByText('question'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('{{question}}')
|
||||
})
|
||||
|
||||
it('returns null when disabled', () => {
|
||||
const { container } = render(<SelectVariable items={mockItems} onSelect={jest.fn()} disabled />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when items is empty', () => {
|
||||
const { container } = render(<SelectVariable items={[]} onSelect={jest.fn()} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders items as a flat list without category headers', () => {
|
||||
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
|
||||
|
||||
// Should NOT have category headers like "CHAT CONTEXT" or "NODE OUTPUTS"
|
||||
expect(screen.queryByText('Chat Context')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Node Outputs')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Box, List, ListItem, ListItemAvatar, ListItemButton, ListItemText, Stack, Typography } from '@mui/material'
|
||||
import { IconBinaryTree, IconHistory, IconMessageChatbot, IconPaperclip } from '@tabler/icons-react'
|
||||
|
||||
export interface VariableItem {
|
||||
label: string
|
||||
description?: string
|
||||
category?: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface SelectVariableProps {
|
||||
items: VariableItem[]
|
||||
disabled?: boolean
|
||||
onSelect: (variableString: string) => void
|
||||
}
|
||||
|
||||
/** Maps each category to an icon and color matching the original Flowise SelectVariable. */
|
||||
const CATEGORY_STYLE: Record<string, { icon: React.ElementType; color: string }> = {
|
||||
'Chat Context': { icon: IconMessageChatbot, color: '#6EC6E6' },
|
||||
'Node Outputs': { icon: IconHistory, color: '#64B5F6' },
|
||||
'Flow State': { icon: IconBinaryTree, color: '#FFA07A' }
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE = { icon: IconPaperclip, color: '#90A4AE' }
|
||||
|
||||
/**
|
||||
* Presentational variable picker atom.
|
||||
*
|
||||
* Renders a flat, scrollable list of variables matching the original Flowise
|
||||
* SelectVariable styling — 50x50 white circle avatars with colored icons,
|
||||
* no category headers, consistent card-like item spacing.
|
||||
*/
|
||||
export function SelectVariable({ items, disabled = false, onSelect }: SelectVariableProps) {
|
||||
if (disabled || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ flex: 30 }}>
|
||||
<Stack flexDirection='row' sx={{ mb: 1, ml: 2, mt: 2 }}>
|
||||
<Typography variant='h5'>Select Variable</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ maxHeight: 'calc(100vh - 220px)', overflowY: 'auto', overflowX: 'hidden', px: 2 }}>
|
||||
<List>
|
||||
{items.map((item, idx) => {
|
||||
const style = CATEGORY_STYLE[item.category ?? ''] || DEFAULT_STYLE
|
||||
const Icon = style.icon
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
key={`${item.value}-${idx}`}
|
||||
sx={{
|
||||
p: 0,
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)',
|
||||
mb: 1
|
||||
}}
|
||||
onClick={() => onSelect(item.value)}
|
||||
>
|
||||
<ListItem alignItems='center'>
|
||||
<ListItemAvatar>
|
||||
<Box
|
||||
sx={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Icon size={30} stroke={1.5} color={style.color} />
|
||||
</Box>
|
||||
</ListItemAvatar>
|
||||
<ListItemText sx={{ ml: 1 }} primary={item.label} secondary={item.description} />
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createRef, type Ref } from 'react'
|
||||
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import { SuggestionDropdown, type SuggestionDropdownRef, type SuggestionItem } from './SuggestionDropdown'
|
||||
|
||||
const theme = createTheme()
|
||||
|
||||
const ITEMS: SuggestionItem[] = [
|
||||
{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' },
|
||||
{ id: 'chat_history', label: 'chat_history', description: 'Conversation history', category: 'Chat Context' },
|
||||
{ id: 'node1.data.instance', label: 'LLM Node', description: 'Output from LLM', category: 'Node Outputs' },
|
||||
{ id: '$flow.state.count', label: '$flow.state.count', description: 'State variable', category: 'Flow State' }
|
||||
]
|
||||
|
||||
function renderDropdown(props: Partial<React.ComponentProps<typeof SuggestionDropdown>> = {}, ref?: Ref<SuggestionDropdownRef>) {
|
||||
const defaultProps = {
|
||||
items: ITEMS,
|
||||
command: jest.fn(),
|
||||
...props
|
||||
}
|
||||
|
||||
const result = render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<SuggestionDropdown ref={ref} {...defaultProps} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
return { ...result, ...defaultProps }
|
||||
}
|
||||
|
||||
describe('SuggestionDropdown', () => {
|
||||
it('renders grouped items by category', () => {
|
||||
renderDropdown()
|
||||
|
||||
expect(screen.getByText('Chat Context')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node Outputs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Flow State')).toBeInTheDocument()
|
||||
expect(screen.getByText('question')).toBeInTheDocument()
|
||||
expect(screen.getByText('LLM Node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when items array is empty', () => {
|
||||
renderDropdown({ items: [] })
|
||||
|
||||
expect(screen.queryByTestId('suggestion-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls command with correct attrs when item is clicked', () => {
|
||||
const { command } = renderDropdown()
|
||||
|
||||
fireEvent.click(screen.getByText('question'))
|
||||
|
||||
expect(command).toHaveBeenCalledWith({ id: 'question', label: 'question' })
|
||||
})
|
||||
|
||||
it('shows descriptions for items', () => {
|
||||
renderDropdown()
|
||||
|
||||
expect(screen.getByText("User's question")).toBeInTheDocument()
|
||||
expect(screen.getByText('Output from LLM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes onKeyDown via ref for Enter selection', () => {
|
||||
const ref = createRef<SuggestionDropdownRef>()
|
||||
const { command } = renderDropdown({}, ref)
|
||||
|
||||
// Enter selects first item (index 0)
|
||||
const handled = ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'Enter' }) })
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(command).toHaveBeenCalledWith({ id: 'question', label: 'question' })
|
||||
})
|
||||
|
||||
it('exposes onKeyDown via ref for ArrowDown navigation', () => {
|
||||
const ref = createRef<SuggestionDropdownRef>()
|
||||
const { command } = renderDropdown({}, ref)
|
||||
|
||||
// ArrowDown moves to index 1 — wrap in act() so React flushes state
|
||||
act(() => {
|
||||
ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) })
|
||||
})
|
||||
// Enter selects index 1
|
||||
act(() => {
|
||||
ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'Enter' }) })
|
||||
})
|
||||
|
||||
expect(command).toHaveBeenCalledWith({ id: 'chat_history', label: 'chat_history' })
|
||||
})
|
||||
|
||||
it('exposes onKeyDown via ref for ArrowUp navigation (wraps around)', () => {
|
||||
const ref = createRef<SuggestionDropdownRef>()
|
||||
const { command } = renderDropdown({}, ref)
|
||||
|
||||
// ArrowUp from index 0 wraps to last — wrap in act() so React flushes state
|
||||
act(() => {
|
||||
ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowUp' }) })
|
||||
})
|
||||
act(() => {
|
||||
ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'Enter' }) })
|
||||
})
|
||||
|
||||
expect(command).toHaveBeenCalledWith({ id: '$flow.state.count', label: '$flow.state.count' })
|
||||
})
|
||||
|
||||
it('returns false for unhandled keys', () => {
|
||||
const ref = createRef<SuggestionDropdownRef>()
|
||||
renderDropdown({}, ref)
|
||||
|
||||
const handled = ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'a' }) })
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Divider, List, ListItem, ListItemButton, Paper, Typography } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
/**
|
||||
* Shape of items returned by the TipTap suggestion `items()` callback.
|
||||
* Matches the UI package's SuggestionList item shape.
|
||||
*/
|
||||
export interface SuggestionItem {
|
||||
id: string
|
||||
/** Display label (called `mentionLabel` in the UI package) */
|
||||
label: string
|
||||
description?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface SuggestionDropdownProps {
|
||||
/** Filtered suggestion items from TipTap's suggestion plugin */
|
||||
items: SuggestionItem[]
|
||||
/** TipTap command to insert the selected mention node */
|
||||
command: (attrs: { id: string; label: string }) => void
|
||||
}
|
||||
|
||||
/** Ref handle exposed to TipTap's suggestion `onKeyDown` callback. */
|
||||
export interface SuggestionDropdownRef {
|
||||
onKeyDown: (args: { event: KeyboardEvent }) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete dropdown for TipTap mention suggestions.
|
||||
*
|
||||
* Rendered by TipTap's suggestion plugin via ReactRenderer.
|
||||
* Exposes keyboard navigation via forwardRef + useImperativeHandle
|
||||
* so the suggestion plugin can delegate keystrokes.
|
||||
*
|
||||
* Port of packages/ui/src/ui-component/input/SuggestionList.jsx to TypeScript.
|
||||
*/
|
||||
export const SuggestionDropdown = forwardRef<SuggestionDropdownRef, SuggestionDropdownProps>(({ items, command }, ref) => {
|
||||
const theme = useTheme()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
// Group items by category
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, SuggestionItem[]> = {}
|
||||
for (const item of items) {
|
||||
const cat = item.category ?? 'Other'
|
||||
if (!groups[cat]) groups[cat] = []
|
||||
groups[cat].push(item)
|
||||
}
|
||||
return groups
|
||||
}, [items])
|
||||
|
||||
// Reset selection when items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [items])
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
const selected = listRef.current.querySelector('[data-selected="true"]')
|
||||
selected?.scrollIntoView?.({ block: 'nearest' })
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
if (index >= items.length) return
|
||||
const item = items[index]
|
||||
command({ id: item.id, label: item.label })
|
||||
}
|
||||
|
||||
// Expose keyboard navigation to TipTap's suggestion plugin
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
selectItem(selectedIndex)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={5}
|
||||
sx={{
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
zIndex: theme.zIndex.modal + 1
|
||||
}}
|
||||
data-testid='suggestion-dropdown'
|
||||
>
|
||||
<List ref={listRef} dense sx={{ overflow: 'hidden', maxWidth: 300 }}>
|
||||
{Object.entries(grouped).map(([category, categoryItems], categoryIndex) => (
|
||||
<div key={category}>
|
||||
{categoryIndex > 0 && <Divider />}
|
||||
<ListItem
|
||||
sx={{
|
||||
py: 0.5,
|
||||
bgcolor: theme.palette.mode === 'dark' ? theme.palette.common.black : theme.palette.grey[50]
|
||||
}}
|
||||
>
|
||||
<Typography variant='overline' color='text.secondary'>
|
||||
{category}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
{categoryItems.map((item) => {
|
||||
const itemIndex = items.findIndex((i) => i.id === item.id)
|
||||
const isSelected = itemIndex === selectedIndex
|
||||
return (
|
||||
<ListItem key={item.id} disablePadding>
|
||||
<ListItemButton
|
||||
data-selected={isSelected}
|
||||
selected={isSelected}
|
||||
onClick={() => selectItem(itemIndex)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<Typography variant='body1' sx={{ fontWeight: 500 }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
{item.description && (
|
||||
<Typography
|
||||
variant='caption'
|
||||
color='text.secondary'
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)
|
||||
})
|
||||
|
||||
SuggestionDropdown.displayName = 'SuggestionDropdown'
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as TiptapReact from '@tiptap/react'
|
||||
|
||||
import { VariableInput, type VariableInputProps } from './VariableInput'
|
||||
|
||||
const theme = createTheme()
|
||||
|
||||
function renderVariableInput(props: Partial<VariableInputProps> = {}) {
|
||||
const defaultProps: VariableInputProps = {
|
||||
value: '',
|
||||
onChange: jest.fn(),
|
||||
...props
|
||||
}
|
||||
|
||||
const result = render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<VariableInput {...defaultProps} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
return { ...result, ...defaultProps }
|
||||
}
|
||||
|
||||
describe('VariableInput', () => {
|
||||
it('renders the TipTap editor container', () => {
|
||||
renderVariableInput()
|
||||
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the TipTap editor content element', () => {
|
||||
renderVariableInput()
|
||||
|
||||
// The mocked EditorContent renders a div with data-testid="tiptap-editor-content"
|
||||
expect(screen.getByTestId('tiptap-editor-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes editor instance to EditorContent', () => {
|
||||
renderVariableInput({ value: 'Hello' })
|
||||
|
||||
const editorContent = screen.getByTestId('tiptap-editor-content')
|
||||
expect(editorContent).toHaveAttribute('data-has-editor', 'true')
|
||||
})
|
||||
|
||||
it('renders without suggestion items', () => {
|
||||
renderVariableInput({ suggestionItems: undefined })
|
||||
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with suggestion items', () => {
|
||||
renderVariableInput({
|
||||
suggestionItems: [{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' }]
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with placeholder', () => {
|
||||
renderVariableInput({ placeholder: 'Type here...' })
|
||||
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders in disabled state', () => {
|
||||
renderVariableInput({ disabled: true })
|
||||
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('initialises the editor with the provided value', () => {
|
||||
// Verify the editor is created with content matching the value prop, so
|
||||
// the editor is not empty on the first render before the sync useEffect fires.
|
||||
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
|
||||
renderVariableInput({ value: 'Hello world' })
|
||||
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ content: 'Hello world' }))
|
||||
useEditorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('initialises the editor with empty string when value is empty', () => {
|
||||
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
|
||||
renderVariableInput({ value: '' })
|
||||
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ content: '' }))
|
||||
useEditorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import { createLowlight } from 'lowlight'
|
||||
|
||||
import { CustomMention } from '@/core/primitives/customMention'
|
||||
import { tokens } from '@/core/theme/tokens'
|
||||
|
||||
import { createSuggestionConfig } from './suggestionConfig'
|
||||
import type { SuggestionItem } from './SuggestionDropdown'
|
||||
|
||||
export type { SuggestionItem }
|
||||
|
||||
const lowlight = createLowlight()
|
||||
lowlight.register('javascript', javascript)
|
||||
lowlight.register('json', json)
|
||||
lowlight.register('python', python)
|
||||
lowlight.register('typescript', typescript)
|
||||
|
||||
export interface VariableInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** Number of visible text rows. When set, renders as a multiline editor and uses markdown serialization. */
|
||||
rows?: number
|
||||
/** Available variables for autocomplete when typing `{{` */
|
||||
suggestionItems?: SuggestionItem[]
|
||||
}
|
||||
|
||||
/* ── Styled wrapper matching RichTextEditor styling ── */
|
||||
|
||||
const StyledEditorContent = styled(EditorContent, {
|
||||
shouldForwardProp: (prop) => !['rows', 'disabled'].includes(prop as string)
|
||||
})<{ rows?: number; disabled?: boolean }>(({ theme, rows, disabled }) => {
|
||||
const mode = theme.palette.mode === 'dark' ? 'dark' : 'light'
|
||||
const sh = tokens.colors.syntaxHighlight
|
||||
|
||||
return {
|
||||
'& .ProseMirror': {
|
||||
padding: '10px 14px',
|
||||
height: rows ? `${rows * tokens.typography.rowHeightRem}rem` : `${tokens.typography.singleLineHeightRem}rem`,
|
||||
overflowY: rows ? 'auto' : 'hidden',
|
||||
overflowX: rows ? 'auto' : 'hidden',
|
||||
lineHeight: rows ? `${tokens.typography.rowHeightRem}em` : `${tokens.typography.singleLineLineHeightEm}em`,
|
||||
fontWeight: 500,
|
||||
color: disabled ? theme.palette.action.disabled : theme.palette.grey[900],
|
||||
border: `1px solid ${theme.palette.grey[900]}25`,
|
||||
borderRadius: '10px',
|
||||
backgroundColor:
|
||||
(theme.palette as { textBackground?: { main: string } }).textBackground?.main ?? theme.palette.background.paper,
|
||||
boxSizing: 'border-box',
|
||||
whiteSpace: rows ? 'pre-wrap' : 'nowrap',
|
||||
|
||||
'&:hover': {
|
||||
borderColor: disabled ? `${theme.palette.grey[900]}25` : theme.palette.text.primary,
|
||||
cursor: disabled ? 'default' : 'text'
|
||||
},
|
||||
'&:focus': {
|
||||
borderColor: disabled ? `${theme.palette.grey[900]}25` : theme.palette.primary.main,
|
||||
outline: 'none'
|
||||
},
|
||||
|
||||
// Placeholder
|
||||
'& p.is-editor-empty:first-of-type::before': {
|
||||
content: 'attr(data-placeholder)',
|
||||
float: 'left',
|
||||
color: disabled ? theme.palette.action.disabled : theme.palette.text.primary,
|
||||
opacity: disabled ? 0.6 : 0.4,
|
||||
pointerEvents: 'none',
|
||||
height: 0
|
||||
},
|
||||
|
||||
// Mention (variable) chip styling — matches original UI green style
|
||||
'& .mention': {
|
||||
backgroundColor: mode === 'dark' ? 'rgba(76, 175, 80, 0.2)' : 'rgba(76, 175, 80, 0.08)',
|
||||
borderRadius: '4px',
|
||||
padding: '1px 4px',
|
||||
fontWeight: 600,
|
||||
color: mode === 'dark' ? '#81c784' : '#2e7d32',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
|
||||
// Block element spacing
|
||||
'& p, & h1, & h2, & h3, & h4, & h5, & h6, & ul, & ol, & pre, & blockquote': {
|
||||
margin: '0.75em 0'
|
||||
},
|
||||
'& > :first-of-type': { marginTop: '0.25em' },
|
||||
'& > :last-of-type': { marginBottom: '0.25em' },
|
||||
|
||||
// List indentation
|
||||
'& ul, & ol': { paddingLeft: '1.5em' },
|
||||
'& li': { marginBottom: '0.25em' },
|
||||
'& li > p': { margin: '0.25em 0' },
|
||||
|
||||
// Code block styling
|
||||
'& pre': {
|
||||
backgroundColor: sh.background[mode],
|
||||
color: sh.text[mode],
|
||||
borderRadius: '8px',
|
||||
padding: '0.75em 1em',
|
||||
overflow: 'auto',
|
||||
'& code': { fontFamily: 'monospace', fontSize: '0.9em' }
|
||||
},
|
||||
|
||||
// Syntax highlight colors
|
||||
'& .hljs-comment, & .hljs-quote': { color: sh.comment[mode] },
|
||||
'& .hljs-variable, & .hljs-template-variable, & .hljs-attr': { color: sh.variable[mode] },
|
||||
'& .hljs-number, & .hljs-literal': { color: sh.number[mode] },
|
||||
'& .hljs-string, & .hljs-regexp': { color: sh.string[mode] },
|
||||
'& .hljs-title, & .hljs-section, & .hljs-selector-id': { color: sh.title[mode] },
|
||||
'& .hljs-keyword, & .hljs-selector-tag, & .hljs-built_in': { color: sh.keyword[mode] },
|
||||
'& .hljs-operator, & .hljs-symbol': { color: sh.operator[mode] },
|
||||
'& .hljs-punctuation': { color: sh.punctuation[mode] }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* A TipTap-based text input with `{{ variable }}` mention support.
|
||||
*
|
||||
* When the user types `{{`, a suggestion dropdown appears anchored to the cursor
|
||||
* with available variables. Selecting a variable inserts it as a styled mention chip
|
||||
* that renders as `{{variableName}}` in the output text.
|
||||
*
|
||||
* Serialization matches the UI's RichInput behavior:
|
||||
* This is the agentflow equivalent of the UI package's RichInput component.
|
||||
*/
|
||||
export function VariableInput({ value, onChange, placeholder, disabled = false, rows, suggestionItems }: VariableInputProps) {
|
||||
const onChangeRef = useRef(onChange)
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
const suggestionConfig = useMemo(
|
||||
() => (suggestionItems?.length ? createSuggestionConfig(suggestionItems) : undefined),
|
||||
[suggestionItems]
|
||||
)
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
StarterKit.configure({
|
||||
codeBlock: false
|
||||
}),
|
||||
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }),
|
||||
...(placeholder ? [Placeholder.configure({ placeholder })] : []),
|
||||
...(suggestionConfig
|
||||
? [
|
||||
CustomMention.configure({
|
||||
HTMLAttributes: { class: 'mention' },
|
||||
renderHTML({ options, node }) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(this.HTMLAttributes ?? {}, options.HTMLAttributes),
|
||||
`${options.suggestion?.char ?? '{{'}${node.attrs.label ?? node.attrs.id}}}`
|
||||
]
|
||||
},
|
||||
suggestion: suggestionConfig,
|
||||
deleteTriggerWithBackspace: true
|
||||
})
|
||||
]
|
||||
: [])
|
||||
],
|
||||
[placeholder, suggestionConfig]
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: value || '',
|
||||
editable: !disabled,
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
// Always use HTML serialization. The @tiptap/markdown v3 getMarkdown()
|
||||
// returns empty strings (known issue with the MarkdownManager in v3.20.4).
|
||||
// HTML round-trips correctly because the Mention extension's parseHTML
|
||||
// matches on span[data-type="mention"] with data-id/data-label attributes.
|
||||
onChangeRef.current(ed.getHTML())
|
||||
}
|
||||
})
|
||||
|
||||
// Sync external value changes into the editor (e.g. when parent state updates)
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value, { emitUpdate: false })
|
||||
}
|
||||
}, [editor, value])
|
||||
|
||||
// Sync editable state
|
||||
useEffect(() => {
|
||||
if (editor) editor.setEditable(!disabled)
|
||||
}, [editor, disabled])
|
||||
|
||||
return (
|
||||
<Box data-testid='variable-input' sx={{ mt: 1 }}>
|
||||
<StyledEditorContent editor={editor} rows={rows} disabled={disabled} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import type { VariableItem } from './VariablePicker'
|
||||
import { VariablePicker } from './VariablePicker'
|
||||
|
||||
const theme = createTheme()
|
||||
|
||||
function renderPicker(props: Partial<React.ComponentProps<typeof VariablePicker>> = {}) {
|
||||
const defaultProps = {
|
||||
items: [] as VariableItem[],
|
||||
onSelect: jest.fn(),
|
||||
...props
|
||||
}
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<VariablePicker {...defaultProps} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const VARIABLES: VariableItem[] = [
|
||||
{ label: 'question', description: "User's question from chatbox", category: 'Chat Context', value: '{{question}}' },
|
||||
{ label: 'chat_history', description: 'Past conversation history', category: 'Chat Context', value: '{{chat_history}}' },
|
||||
{ label: 'nodeA', description: 'Output from Node A', category: 'Node Outputs', value: '{{nodeA.data.instance}}' },
|
||||
{ label: '$flow.state.count', description: 'Counter state', category: 'Flow State', value: '$flow.state.count' }
|
||||
]
|
||||
|
||||
describe('VariablePicker', () => {
|
||||
it('renders null when disabled', () => {
|
||||
const { container } = renderPicker({ items: VARIABLES, disabled: true })
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders null when items array is empty', () => {
|
||||
const { container } = renderPicker({ items: [] })
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders the "Select Variable" heading', () => {
|
||||
renderPicker({ items: VARIABLES })
|
||||
expect(screen.getByText('Select Variable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders category headers', () => {
|
||||
renderPicker({ items: VARIABLES })
|
||||
expect(screen.getByText('Chat Context')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node Outputs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Flow State')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders variable labels and descriptions', () => {
|
||||
renderPicker({ items: VARIABLES })
|
||||
expect(screen.getByText('question')).toBeInTheDocument()
|
||||
expect(screen.getByText("User's question from chatbox")).toBeInTheDocument()
|
||||
expect(screen.getByText('chat_history')).toBeInTheDocument()
|
||||
expect(screen.getByText('nodeA')).toBeInTheDocument()
|
||||
expect(screen.getByText('$flow.state.count')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('groups items by category', () => {
|
||||
renderPicker({ items: VARIABLES })
|
||||
// Chat Context should contain both question and chat_history
|
||||
const listItems = screen.getAllByRole('button')
|
||||
expect(listItems).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('calls onSelect with the variable value when item is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = jest.fn()
|
||||
renderPicker({ items: VARIABLES, onSelect })
|
||||
|
||||
await user.click(screen.getByText('question'))
|
||||
expect(onSelect).toHaveBeenCalledWith('{{question}}')
|
||||
})
|
||||
|
||||
it('calls onSelect with the correct value for each item', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = jest.fn()
|
||||
renderPicker({ items: VARIABLES, onSelect })
|
||||
|
||||
await user.click(screen.getByText('$flow.state.count'))
|
||||
expect(onSelect).toHaveBeenCalledWith('$flow.state.count')
|
||||
})
|
||||
|
||||
it('uses "Other" category for items without a category', () => {
|
||||
const items: VariableItem[] = [{ label: 'custom', description: 'A custom var', value: '{{custom}}' }]
|
||||
renderPicker({ items })
|
||||
expect(screen.getByText('Other')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Box, Divider, List, ListItem, ListItemAvatar, ListItemButton, ListItemText, Stack, Typography } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { IconBinaryTree, IconHistory, IconMessageChatbot, IconPaperclip } from '@tabler/icons-react'
|
||||
|
||||
export interface VariableItem {
|
||||
label: string
|
||||
description?: string
|
||||
category?: string
|
||||
value: string
|
||||
/** Optional per-item icon component (e.g. the upstream node's icon). */
|
||||
icon?: React.ElementType
|
||||
/** Optional per-item icon color. Falls back to the category color. */
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
export interface VariablePickerProps {
|
||||
items: VariableItem[]
|
||||
onSelect: (variableString: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const CATEGORY_STYLE: Record<string, { icon: React.ElementType; color: string }> = {
|
||||
'Chat Context': { icon: IconMessageChatbot, color: '#6EC6E6' },
|
||||
'Node Outputs': { icon: IconHistory, color: '#64B5F6' },
|
||||
'Flow State': { icon: IconBinaryTree, color: '#FFA07A' }
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE = { icon: IconPaperclip, color: '#90A4AE' }
|
||||
|
||||
/**
|
||||
* Grouped variable picker panel. Shows variables organized by category with
|
||||
* section headers and colored icons. Used in popovers (e.g. JSON editor per-key
|
||||
* injection, non-TipTap variable selection).
|
||||
*/
|
||||
export function VariablePicker({ items, onSelect, disabled = false }: VariablePickerProps) {
|
||||
const theme = useTheme()
|
||||
const grouped = useMemo(() => {
|
||||
const groups: { category: string; items: VariableItem[] }[] = []
|
||||
const seen = new Map<string, VariableItem[]>()
|
||||
for (const item of items) {
|
||||
const cat = item.category ?? 'Other'
|
||||
if (!seen.has(cat)) {
|
||||
const arr: VariableItem[] = []
|
||||
seen.set(cat, arr)
|
||||
groups.push({ category: cat, items: arr })
|
||||
}
|
||||
seen.get(cat)!.push(item)
|
||||
}
|
||||
return groups
|
||||
}, [items])
|
||||
|
||||
if (disabled || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ flex: 30 }}>
|
||||
<Stack flexDirection='row' sx={{ mb: 1, ml: 2, mt: 2 }}>
|
||||
<Typography variant='h5'>Select Variable</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ maxHeight: 'calc(100vh - 220px)', overflowY: 'auto', overflowX: 'hidden', px: 2 }}>
|
||||
{grouped.map((group, groupIdx) => {
|
||||
const style = CATEGORY_STYLE[group.category] || DEFAULT_STYLE
|
||||
const Icon = style.icon
|
||||
|
||||
return (
|
||||
<Box key={group.category}>
|
||||
{groupIdx > 0 && <Divider sx={{ my: 1 }} />}
|
||||
<Typography
|
||||
variant='overline'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mt: 1,
|
||||
mb: 0.5,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
<Icon size={16} color={style.color} />
|
||||
{group.category}
|
||||
</Typography>
|
||||
<List disablePadding>
|
||||
{group.items.map((item, idx) => {
|
||||
const ItemIcon = item.icon ?? Icon
|
||||
const itemColor = item.iconColor ?? style.color
|
||||
return (
|
||||
<ListItemButton
|
||||
key={`${item.value}-${idx}`}
|
||||
sx={{
|
||||
p: 0,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)',
|
||||
mb: 1
|
||||
}}
|
||||
onClick={() => onSelect(item.value)}
|
||||
>
|
||||
<ListItem alignItems='center'>
|
||||
<ListItemAvatar>
|
||||
<Box
|
||||
sx={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ItemIcon size={30} stroke={1.5} color={itemColor} />
|
||||
</Box>
|
||||
</ListItemAvatar>
|
||||
<ListItemText sx={{ ml: 1 }} primary={item.label} secondary={item.description} />
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export { JsonInput, type JsonInputProps } from './JsonInput'
|
||||
export { MainCard, type MainCardProps } from './MainCard'
|
||||
export { type MessageEntry, MessagesInput, type MessagesInputProps } from './MessagesInput'
|
||||
export { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
|
||||
export { SelectVariable, type SelectVariableProps, type VariableItem } from './SelectVariable'
|
||||
// RichTextEditor is exported from the .lazy wrapper (not the real module) to avoid
|
||||
// eagerly pulling TipTap + highlight.js into the main bundle. Importing directly
|
||||
// from ./RichTextEditor would defeat code-splitting since barrel imports are resolved eagerly.
|
||||
@@ -17,5 +16,8 @@ export type { RichTextEditorProps } from './RichTextEditor'
|
||||
export { RichTextEditor } from './RichTextEditor.lazy'
|
||||
export { ScenariosInput, type ScenariosInputProps } from './ScenariosInput'
|
||||
export { StructuredOutputBuilder, type StructuredOutputBuilderProps, type StructuredOutputEntry } from './StructuredOutputBuilder'
|
||||
export { SuggestionDropdown, type SuggestionDropdownProps, type SuggestionDropdownRef, type SuggestionItem } from './SuggestionDropdown'
|
||||
export { SwitchInput, type SwitchInputProps } from './SwitchInput'
|
||||
export { TooltipWithParser, type TooltipWithParserProps } from './TooltipWithParser'
|
||||
export { VariableInput, type VariableInputProps } from './VariableInput'
|
||||
export { type VariableItem, VariablePicker, type VariablePickerProps } from './VariablePicker'
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
import { createSuggestionConfig } from './suggestionConfig'
|
||||
import type { SuggestionItem } from './SuggestionDropdown'
|
||||
|
||||
const ITEMS: SuggestionItem[] = [
|
||||
{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' },
|
||||
{ id: 'chat_history', label: 'chat_history', description: 'Past conversation', category: 'Chat Context' },
|
||||
{ id: '$flow.state.count', label: '$flow.state.count', description: 'Counter', category: 'Flow State' }
|
||||
]
|
||||
|
||||
describe('createSuggestionConfig', () => {
|
||||
const config = createSuggestionConfig(ITEMS)
|
||||
|
||||
it('uses {{ as trigger character', () => {
|
||||
expect(config.char).toBe('{{')
|
||||
})
|
||||
|
||||
describe('items() filtering', () => {
|
||||
const items = config.items as (args: { query: string }) => SuggestionItem[]
|
||||
|
||||
it('returns all items when query is empty', () => {
|
||||
expect(items({ query: '' })).toEqual(ITEMS)
|
||||
})
|
||||
|
||||
it('filters by label (case-insensitive)', () => {
|
||||
const result = items({ query: 'QUESTION' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('question')
|
||||
})
|
||||
|
||||
it('filters by id', () => {
|
||||
const result = items({ query: '$flow.state' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('$flow.state.count')
|
||||
})
|
||||
|
||||
it('matches partial strings', () => {
|
||||
const result = items({ query: 'chat' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('chat_history')
|
||||
})
|
||||
|
||||
it('returns empty array when nothing matches', () => {
|
||||
expect(items({ query: 'nonexistent' })).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('render lifecycle', () => {
|
||||
const mockEditor = { view: { dom: document.createElement('div') } }
|
||||
const mockClientRect = jest.fn(() => new DOMRect(10, 20, 100, 30))
|
||||
|
||||
function makeProps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
editor: mockEditor,
|
||||
range: { from: 0, to: 5 },
|
||||
query: '',
|
||||
text: '',
|
||||
items: ITEMS,
|
||||
command: jest.fn(),
|
||||
decorationNode: null,
|
||||
clientRect: mockClientRect,
|
||||
...overrides
|
||||
} as unknown as Parameters<NonNullable<ReturnType<typeof config.render>['onStart']>>[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('onStart creates a ReactRenderer and tippy popup', () => {
|
||||
const lifecycle = config.render()
|
||||
lifecycle.onStart!(makeProps())
|
||||
|
||||
expect(ReactRenderer).toHaveBeenCalledTimes(1)
|
||||
expect(tippy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('onUpdate refreshes component props and popup position', () => {
|
||||
const lifecycle = config.render()
|
||||
lifecycle.onStart!(makeProps())
|
||||
|
||||
const renderer = (ReactRenderer as jest.Mock).mock.results[0].value
|
||||
const tippyInstance = (tippy as unknown as jest.Mock).mock.results[0].value[0]
|
||||
|
||||
lifecycle.onUpdate!(makeProps())
|
||||
expect(renderer.updateProps).toHaveBeenCalled()
|
||||
expect(tippyInstance.setProps).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onKeyDown returns true for Escape and hides popup', () => {
|
||||
const lifecycle = config.render()
|
||||
lifecycle.onStart!(makeProps())
|
||||
|
||||
const tippyInstance = (tippy as unknown as jest.Mock).mock.results[0].value[0]
|
||||
const result = lifecycle.onKeyDown!({
|
||||
view: {} as never,
|
||||
event: new KeyboardEvent('keydown', { key: 'Escape' }),
|
||||
range: { from: 0, to: 5 }
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(tippyInstance.hide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onKeyDown returns false when component ref is null', () => {
|
||||
const lifecycle = config.render()
|
||||
lifecycle.onStart!(makeProps())
|
||||
|
||||
const result = lifecycle.onKeyDown!({
|
||||
view: {} as never,
|
||||
event: new KeyboardEvent('keydown', { key: 'ArrowDown' }),
|
||||
range: { from: 0, to: 5 }
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('onKeyDown delegates to component ref when available', () => {
|
||||
const lifecycle = config.render()
|
||||
lifecycle.onStart!(makeProps())
|
||||
|
||||
const renderer = (ReactRenderer as jest.Mock).mock.results[0].value
|
||||
const mockOnKeyDown = jest.fn(() => true)
|
||||
renderer.ref = { onKeyDown: mockOnKeyDown }
|
||||
|
||||
const props = {
|
||||
view: {} as never,
|
||||
event: new KeyboardEvent('keydown', { key: 'ArrowDown' }),
|
||||
range: { from: 0, to: 5 }
|
||||
}
|
||||
const result = lifecycle.onKeyDown!(props)
|
||||
|
||||
expect(mockOnKeyDown).toHaveBeenCalledWith(props)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('onExit destroys component and popup', () => {
|
||||
const lifecycle = config.render()
|
||||
lifecycle.onStart!(makeProps())
|
||||
|
||||
const renderer = (ReactRenderer as jest.Mock).mock.results[0].value
|
||||
const tippyInstance = (tippy as unknown as jest.Mock).mock.results[0].value[0]
|
||||
|
||||
lifecycle.onExit!()
|
||||
|
||||
expect(renderer.destroy).toHaveBeenCalled()
|
||||
expect(tippyInstance.destroy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses DOM_RECT_FALLBACK when clientRect is null', () => {
|
||||
const lifecycle = config.render()
|
||||
lifecycle.onStart!(makeProps({ clientRect: null }))
|
||||
|
||||
// Verify tippy was called — the fallback is used inside getReferenceClientRect
|
||||
const tippyConfig = (tippy as unknown as jest.Mock).mock.calls[0][1]
|
||||
const rect = tippyConfig.getReferenceClientRect()
|
||||
// Should return the fallback DOMRect (all zeros)
|
||||
expect(rect.x).toBe(0)
|
||||
expect(rect.y).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* TipTap suggestion render lifecycle using tippy.js and ReactRenderer.
|
||||
*
|
||||
* Port of packages/ui/src/ui-component/input/suggestionOption.js render() to TypeScript,
|
||||
* adapted for the agentflow SuggestionDropdown component.
|
||||
*/
|
||||
import type { Editor, Range } from '@tiptap/core'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import tippy, { type Instance as TippyInstance } from 'tippy.js'
|
||||
|
||||
import { SuggestionDropdown, type SuggestionDropdownRef, type SuggestionItem } from './SuggestionDropdown'
|
||||
|
||||
interface SuggestionCallbackProps {
|
||||
editor: Editor
|
||||
range: Range
|
||||
query: string
|
||||
text: string
|
||||
items: SuggestionItem[]
|
||||
command: (props: SuggestionItem) => void
|
||||
decorationNode: Element | null
|
||||
clientRect?: (() => DOMRect | null) | null
|
||||
}
|
||||
|
||||
interface SuggestionKeyDownCallbackProps {
|
||||
view: EditorView
|
||||
event: KeyboardEvent
|
||||
range: Range
|
||||
}
|
||||
|
||||
/**
|
||||
* Workaround for the typing incompatibility between Tippy.js and TipTap Suggestion utility.
|
||||
* @see https://github.com/ueberdosis/tiptap/issues/2795#issuecomment-1160623792
|
||||
*/
|
||||
const DOM_RECT_FALLBACK: DOMRect = {
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON() {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SuggestionConfigOptions {
|
||||
items: SuggestionItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the TipTap suggestion config object for the CustomMention extension.
|
||||
*
|
||||
* @param suggestionItems - The full list of available variable items.
|
||||
* Filtering by query is done inside the `items` callback.
|
||||
*/
|
||||
export function createSuggestionConfig(suggestionItems: SuggestionItem[]) {
|
||||
return {
|
||||
char: '{{',
|
||||
|
||||
items: ({ query }: { query: string }) => {
|
||||
if (!query) return suggestionItems
|
||||
const q = query.toLowerCase()
|
||||
return suggestionItems.filter((item) => item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q))
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<SuggestionDropdownRef> | undefined
|
||||
let popup: TippyInstance | undefined
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionCallbackProps) => {
|
||||
component = new ReactRenderer(SuggestionDropdown, {
|
||||
props,
|
||||
editor: props.editor
|
||||
})
|
||||
|
||||
const [instance] = tippy('body', {
|
||||
getReferenceClientRect: () => props.clientRect?.() ?? DOM_RECT_FALLBACK,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start'
|
||||
})
|
||||
popup = instance
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionCallbackProps) {
|
||||
component?.updateProps(props)
|
||||
popup?.setProps({
|
||||
getReferenceClientRect: () => props.clientRect?.() ?? DOM_RECT_FALLBACK
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownCallbackProps) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.hide()
|
||||
return true
|
||||
}
|
||||
if (!component?.ref) return false
|
||||
return component.ref.onKeyDown(props)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup?.destroy()
|
||||
component?.destroy()
|
||||
popup = undefined
|
||||
component = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Import triggers Mention.extend() at module level — must come before we read mock.calls.
|
||||
import './customMention'
|
||||
|
||||
import { PasteRule } from '@tiptap/core'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
|
||||
// Grab the config object passed to Mention.extend() so we can test each property.
|
||||
const extendConfig = (Mention.extend as jest.Mock).mock.calls[0][0] as Record<string, unknown>
|
||||
|
||||
describe('CustomMention extension config', () => {
|
||||
describe('renderText', () => {
|
||||
const renderText = extendConfig.renderText as (args: { node: { attrs: Record<string, string> } }) => string
|
||||
|
||||
it('renders label in double-brace syntax', () => {
|
||||
expect(renderText({ node: { attrs: { label: 'question', id: 'q1' } } })).toBe('{{question}}')
|
||||
})
|
||||
|
||||
it('falls back to id when label is missing', () => {
|
||||
expect(renderText({ node: { attrs: { id: 'chat_history' } } })).toBe('{{chat_history}}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdownTokenizer', () => {
|
||||
const tokenizer = extendConfig.markdownTokenizer as {
|
||||
name: string
|
||||
level: string
|
||||
start: string
|
||||
tokenize: (src: string) => { type: string; raw: string; label: string } | undefined
|
||||
}
|
||||
|
||||
it('has correct name and level', () => {
|
||||
expect(tokenizer.name).toBe('mention')
|
||||
expect(tokenizer.level).toBe('inline')
|
||||
expect(tokenizer.start).toBe('{{')
|
||||
})
|
||||
|
||||
it('tokenizes {{variable}} syntax', () => {
|
||||
const result = tokenizer.tokenize('{{question}} rest')
|
||||
expect(result).toEqual({ type: 'mention', raw: '{{question}}', label: 'question' })
|
||||
})
|
||||
|
||||
it('trims whitespace in label', () => {
|
||||
const result = tokenizer.tokenize('{{ chat_history }}')
|
||||
expect(result).toEqual({ type: 'mention', raw: '{{ chat_history }}', label: 'chat_history' })
|
||||
})
|
||||
|
||||
it('returns undefined for non-matching input', () => {
|
||||
expect(tokenizer.tokenize('plain text')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for single braces', () => {
|
||||
expect(tokenizer.tokenize('{question}')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not match nested braces', () => {
|
||||
expect(tokenizer.tokenize('{{outer{{inner}}}}')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMarkdown', () => {
|
||||
const parseMarkdown = extendConfig.parseMarkdown as (token: { label: string }) => {
|
||||
type: string
|
||||
attrs: { id: string; label: string }
|
||||
}
|
||||
|
||||
it('converts token to mention node attrs', () => {
|
||||
expect(parseMarkdown({ label: 'question' })).toEqual({
|
||||
type: 'mention',
|
||||
attrs: { id: 'question', label: 'question' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
const renderMarkdown = extendConfig.renderMarkdown as (node: { attrs?: Record<string, string> }) => string
|
||||
|
||||
it('renders label in double-brace syntax', () => {
|
||||
expect(renderMarkdown({ attrs: { label: 'question', id: 'q1' } })).toBe('{{question}}')
|
||||
})
|
||||
|
||||
it('falls back to id when label is missing', () => {
|
||||
expect(renderMarkdown({ attrs: { id: 'chat_history' } })).toBe('{{chat_history}}')
|
||||
})
|
||||
|
||||
it('handles undefined attrs gracefully', () => {
|
||||
expect(renderMarkdown({})).toBe('{{undefined}}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addPasteRules', () => {
|
||||
// addPasteRules is a function bound to `this` in the extension context
|
||||
const addPasteRules = extendConfig.addPasteRules as () => PasteRule[]
|
||||
|
||||
it('returns an array with one PasteRule', () => {
|
||||
const rules = addPasteRules.call({ name: 'mention' })
|
||||
expect(rules).toHaveLength(1)
|
||||
expect(rules[0]).toBeInstanceOf(PasteRule)
|
||||
})
|
||||
|
||||
it('paste rule has a global regex for {{...}} pattern', () => {
|
||||
const rules = addPasteRules.call({ name: 'mention' })
|
||||
expect(rules[0].find).toEqual(/\{\{([^{}]+)\}\}/g)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdownTokenName', () => {
|
||||
it('is "mention"', () => {
|
||||
expect(extendConfig.markdownTokenName).toBe('mention')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Custom TipTap Mention extension for `{{ variable }}` syntax.
|
||||
*
|
||||
* Port of packages/ui/src/utils/customMention.js to TypeScript.
|
||||
* Includes markdown tokenizer/parser/renderer for @tiptap/markdown integration.
|
||||
*/
|
||||
import { type JSONContent, type MarkdownToken, PasteRule } from '@tiptap/core'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
|
||||
export const CustomMention = Mention.extend({
|
||||
/**
|
||||
* Render mention nodes as `{{label}}` in plain text output.
|
||||
*/
|
||||
renderText({ node }) {
|
||||
return `{{${node.attrs.label ?? node.attrs.id}}}`
|
||||
},
|
||||
|
||||
// ── @tiptap/markdown integration ─────────────────────────────────────────
|
||||
// These properties are read by @tiptap/markdown's MarkdownManager via
|
||||
// getExtensionField() to enable markdown round-tripping of {{variable}} syntax.
|
||||
// Ported from packages/ui/src/utils/customMention.js.
|
||||
|
||||
/** Token name for the custom markdown tokenizer. */
|
||||
markdownTokenName: 'mention',
|
||||
|
||||
/** Custom MarkedJS tokenizer to recognize {{...}} syntax in markdown input. */
|
||||
markdownTokenizer: {
|
||||
name: 'mention',
|
||||
level: 'inline',
|
||||
start: '{{',
|
||||
tokenize(src: string) {
|
||||
const match = src.match(/^\{\{([^{}]+)\}\}/)
|
||||
if (match) {
|
||||
return {
|
||||
type: 'mention',
|
||||
raw: match[0],
|
||||
label: match[1].trim()
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
|
||||
/** Parse a markdown mention token into a TipTap mention node. */
|
||||
parseMarkdown(token: MarkdownToken) {
|
||||
return {
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
id: token.label,
|
||||
label: token.label
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Serialize a mention node back to {{label}} in markdown output. */
|
||||
renderMarkdown(node: JSONContent) {
|
||||
return `{{${node.attrs?.label ?? node.attrs?.id}}}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Paste rule: auto-convert `{{variable}}` text into mention nodes on paste.
|
||||
*/
|
||||
addPasteRules() {
|
||||
return [
|
||||
new PasteRule({
|
||||
find: /\{\{([^{}]+)\}\}/g,
|
||||
handler: ({ match, chain, range }) => {
|
||||
const label = match[1].trim()
|
||||
if (label) {
|
||||
chain()
|
||||
.deleteRange(range)
|
||||
.insertContentAt(range.from, {
|
||||
type: this.name,
|
||||
attrs: { id: label, label }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -1,2 +1,3 @@
|
||||
// Domain-free utility functions safe for use in any layer (including atoms)
|
||||
export { CustomMention } from './customMention'
|
||||
export { getDefaultValueForType } from './inputDefaults'
|
||||
|
||||
@@ -9,3 +9,6 @@ export { evaluateFieldVisibility, evaluateParamVisibility, stripHiddenFieldValue
|
||||
|
||||
// Dynamic output anchor utilities
|
||||
export { buildDynamicOutputAnchors, parseOutputHandleIndex } from './dynamicOutputAnchors'
|
||||
|
||||
// Variable utilities
|
||||
export { getUpstreamNodes } from './variableUtils'
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { extractVariables, getUpstreamNodes } from './variableUtils'
|
||||
|
||||
// ── extractVariables ────────────────────────────────────────────────────────
|
||||
|
||||
describe('extractVariables', () => {
|
||||
it('returns empty array for empty string', () => {
|
||||
expect(extractVariables('')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for text without variables', () => {
|
||||
expect(extractVariables('Hello world')).toEqual([])
|
||||
})
|
||||
|
||||
it('extracts a single variable', () => {
|
||||
expect(extractVariables('Hello {{question}}')).toEqual(['question'])
|
||||
})
|
||||
|
||||
it('extracts multiple variables', () => {
|
||||
expect(extractVariables('{{question}} and {{chat_history}}')).toEqual(['question', 'chat_history'])
|
||||
})
|
||||
|
||||
it('extracts node output references', () => {
|
||||
expect(extractVariables('Result: {{node1.data.instance}}')).toEqual(['node1.data.instance'])
|
||||
})
|
||||
|
||||
it('extracts flow state variables', () => {
|
||||
expect(extractVariables('Count is {{$flow.state.count}}')).toEqual(['$flow.state.count'])
|
||||
})
|
||||
|
||||
it('trims whitespace inside braces', () => {
|
||||
expect(extractVariables('{{ question }}')).toEqual(['question'])
|
||||
})
|
||||
|
||||
it('does not match single braces', () => {
|
||||
expect(extractVariables('{notAVariable}')).toEqual([])
|
||||
})
|
||||
|
||||
it('skips JSON-like content with colons', () => {
|
||||
expect(extractVariables('{{"key": "value"}}')).toEqual([])
|
||||
expect(extractVariables('{{"name": "test"}} and {{question}}')).toEqual(['question'])
|
||||
})
|
||||
|
||||
it('does not match nested braces', () => {
|
||||
expect(extractVariables('{{{nested}}}')).toEqual(['nested'])
|
||||
})
|
||||
|
||||
it('does not match unclosed braces', () => {
|
||||
expect(extractVariables('{{unclosed')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles mixed text and variables', () => {
|
||||
expect(extractVariables('Say {{question}} to {{node1.data.instance}} please')).toEqual(['question', 'node1.data.instance'])
|
||||
})
|
||||
})
|
||||
|
||||
// ── getUpstreamNodes ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getUpstreamNodes', () => {
|
||||
const makeNode = (id: string, overrides?: { name?: string; parentNode?: string }) =>
|
||||
({
|
||||
id,
|
||||
type: 'customNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id, name: overrides?.name ?? id, label: id },
|
||||
...(overrides?.parentNode ? { parentNode: overrides.parentNode } : {})
|
||||
} as {
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
data: { id: string; name: string; label: string }
|
||||
parentNode?: string
|
||||
})
|
||||
|
||||
const makeEdge = (source: string, target: string) => ({
|
||||
id: `${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
type: 'default'
|
||||
})
|
||||
|
||||
it('returns empty array when no edges target the node', () => {
|
||||
const nodes = [makeNode('a'), makeNode('b')]
|
||||
const edges = [makeEdge('a', 'b')]
|
||||
expect(getUpstreamNodes('a', nodes, edges)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns direct upstream nodes', () => {
|
||||
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')]
|
||||
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')]
|
||||
const upstream = getUpstreamNodes('b', nodes, edges)
|
||||
expect(upstream).toHaveLength(1)
|
||||
expect(upstream[0].id).toBe('a')
|
||||
})
|
||||
|
||||
it('returns multiple upstream nodes', () => {
|
||||
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')]
|
||||
const edges = [makeEdge('a', 'c'), makeEdge('b', 'c')]
|
||||
const upstream = getUpstreamNodes('c', nodes, edges)
|
||||
expect(upstream).toHaveLength(2)
|
||||
expect(upstream.map((n) => n.id).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('does not return downstream nodes', () => {
|
||||
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')]
|
||||
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')]
|
||||
const upstream = getUpstreamNodes('a', nodes, edges)
|
||||
expect(upstream).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty array when edges array is empty', () => {
|
||||
const nodes = [makeNode('a')]
|
||||
expect(getUpstreamNodes('a', nodes, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('recursively collects the full ancestor chain', () => {
|
||||
// a → b → c → d: querying from d should return [a, b, c]
|
||||
const nodes = [makeNode('a'), makeNode('b'), makeNode('c'), makeNode('d')]
|
||||
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c'), makeEdge('c', 'd')]
|
||||
const upstream = getUpstreamNodes('d', nodes, edges)
|
||||
expect(upstream.map((n) => n.id).sort()).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('handles diamond-shaped graphs without duplicates', () => {
|
||||
// a
|
||||
// / \
|
||||
// b c
|
||||
// \ /
|
||||
// d
|
||||
const nodes = [makeNode('a'), makeNode('b'), makeNode('c'), makeNode('d')]
|
||||
const edges = [makeEdge('a', 'b'), makeEdge('a', 'c'), makeEdge('b', 'd'), makeEdge('c', 'd')]
|
||||
const upstream = getUpstreamNodes('d', nodes, edges)
|
||||
expect(upstream.map((n) => n.id).sort()).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('excludes startAgentflow by default', () => {
|
||||
const nodes = [makeNode('start_0', { name: 'startAgentflow' }), makeNode('agent_0')]
|
||||
const edges = [makeEdge('start_0', 'agent_0')]
|
||||
const upstream = getUpstreamNodes('agent_0', nodes, edges)
|
||||
expect(upstream).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes startAgentflow when includeStart is true', () => {
|
||||
const nodes = [makeNode('start_0', { name: 'startAgentflow' }), makeNode('agent_0')]
|
||||
const edges = [makeEdge('start_0', 'agent_0')]
|
||||
const upstream = getUpstreamNodes('agent_0', nodes, edges, true)
|
||||
expect(upstream).toHaveLength(1)
|
||||
expect(upstream[0].id).toBe('start_0')
|
||||
})
|
||||
|
||||
it('excludes startAgentflow but still collects nodes behind it', () => {
|
||||
// In practice startAgentflow is the root, but this tests the exclusion logic:
|
||||
// llm_0 → start_0 (excluded) → agent_0
|
||||
const nodes = [makeNode('llm_0'), makeNode('start_0', { name: 'startAgentflow' }), makeNode('agent_0')]
|
||||
const edges = [makeEdge('llm_0', 'start_0'), makeEdge('start_0', 'agent_0')]
|
||||
const upstream = getUpstreamNodes('agent_0', nodes, edges)
|
||||
// start_0 is excluded, but llm_0 should NOT be collected because
|
||||
// we skip start_0 entirely (don't recurse through excluded nodes)
|
||||
expect(upstream).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('traverses parentNode for nodes inside iteration groups', () => {
|
||||
// group_0 contains child_0 (via parentNode). outer_0 → group_0.
|
||||
// Querying from child_0 should find outer_0 via the parentNode traversal.
|
||||
const nodes = [makeNode('outer_0'), makeNode('group_0'), makeNode('child_0', { parentNode: 'group_0' })]
|
||||
const edges = [makeEdge('outer_0', 'group_0')]
|
||||
const upstream = getUpstreamNodes('child_0', nodes, edges)
|
||||
expect(upstream.map((n) => n.id)).toContain('outer_0')
|
||||
})
|
||||
|
||||
it('handles cycles without infinite loop', () => {
|
||||
// a → b → a (cycle)
|
||||
const nodes = [makeNode('a'), makeNode('b')]
|
||||
const edges = [makeEdge('a', 'b'), makeEdge('b', 'a')]
|
||||
const upstream = getUpstreamNodes('a', nodes, edges)
|
||||
expect(upstream).toHaveLength(1)
|
||||
expect(upstream[0].id).toBe('b')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FlowEdge, FlowNode } from '@/core/types'
|
||||
|
||||
/** Regex that matches `{{variablePath}}` tokens in text. Use with `g` flag. */
|
||||
export const VARIABLE_REGEX = /\{\{([^{}]+)\}\}/g
|
||||
|
||||
/**
|
||||
* Extract all variable paths from a string containing `{{variable}}` tokens.
|
||||
*
|
||||
* @example
|
||||
* extractVariables('Hello {{question}}, see {{node1.data.instance}}')
|
||||
* // => ['question', 'node1.data.instance']
|
||||
*/
|
||||
export function extractVariables(text: string): string[] {
|
||||
const matches: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
// Reset lastIndex for safety (global regex)
|
||||
VARIABLE_REGEX.lastIndex = 0
|
||||
while ((match = VARIABLE_REGEX.exec(text)) !== null) {
|
||||
const inside = match[1].trim()
|
||||
// Skip JSON-like content (e.g. {{"key": "value"}})
|
||||
if (!inside.includes(':')) {
|
||||
matches.push(inside)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk edges backward from `nodeId` to collect **all** ancestor nodes
|
||||
* in the AgentFlow V2 graph. Excludes `startAgentflow` by default (its state
|
||||
* variables are handled separately by `useAvailableVariables`). Also traverses
|
||||
* the `parentNode` property for nodes inside iteration groups.
|
||||
*
|
||||
* Matches the `collectAgentFlowV2ParentNodes` logic in
|
||||
* packages/ui/src/utils/genericHelper.js:655-672.
|
||||
*
|
||||
* @param includeStart When true, include `startAgentflow` in the results.
|
||||
*/
|
||||
export function getUpstreamNodes(nodeId: string, nodes: FlowNode[], edges: FlowEdge[], includeStart = false): FlowNode[] {
|
||||
const collected = new Set<string>()
|
||||
// Never include the queried node itself (prevents self-reference in cycles)
|
||||
collected.add(nodeId)
|
||||
|
||||
function collect(targetId: string) {
|
||||
// In AgentFlow V2, targetHandle === targetNodeId for node-level connections
|
||||
const inputEdges = edges.filter((e) => e.target === targetId)
|
||||
|
||||
for (const edge of inputEdges) {
|
||||
if (collected.has(edge.source)) continue
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === edge.source)
|
||||
if (!parentNode) continue
|
||||
|
||||
// Exclude startAgentflow unless explicitly requested
|
||||
if (parentNode.data.name === 'startAgentflow' && !includeStart) {
|
||||
continue
|
||||
}
|
||||
|
||||
collected.add(parentNode.id)
|
||||
// Recurse to collect the full ancestor chain
|
||||
collect(parentNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
collect(nodeId)
|
||||
|
||||
// Also traverse the parentNode property (for nodes inside iteration groups)
|
||||
const targetNode = nodes.find((n) => n.id === nodeId)
|
||||
if (targetNode?.parentNode) {
|
||||
collect(targetNode.parentNode)
|
||||
}
|
||||
|
||||
return nodes.filter((n) => n.id !== nodeId && collected.has(n.id))
|
||||
}
|
||||
@@ -138,12 +138,28 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogProps.inputParams) {
|
||||
const initialValues = dialogProps.data?.inputs || {}
|
||||
// Merge param defaults into inputValues so that fields with show/hide
|
||||
// conditions referencing other fields with defaults evaluate correctly
|
||||
// on first load (e.g., llmEnableMemory defaults to true).
|
||||
const storedValues = dialogProps.data?.inputs || {}
|
||||
const initialValues: Record<string, unknown> = { ...storedValues }
|
||||
for (const param of dialogProps.inputParams) {
|
||||
if (param.default !== undefined && !(param.name in initialValues)) {
|
||||
initialValues[param.name] = param.default
|
||||
}
|
||||
}
|
||||
const evaluatedParams = evaluateFieldVisibility(dialogProps.inputParams, initialValues)
|
||||
setInputParams(evaluatedParams)
|
||||
setArrayItemParameters(computeArrayItemParameters(dialogProps.inputParams, initialValues))
|
||||
}
|
||||
if (dialogProps.data) {
|
||||
|
||||
// Store data with defaults merged into inputValues so that subsequent
|
||||
// onCustomDataChange calls (e.g., triggered by VariableInput's onUpdate
|
||||
// on mount) preserve the defaults for visibility evaluation.
|
||||
if (dialogProps.data) {
|
||||
setData({ ...dialogProps.data, inputs: initialValues })
|
||||
if (dialogProps.data.label) setNodeName(dialogProps.data.label)
|
||||
}
|
||||
} else if (dialogProps.data) {
|
||||
setData(dialogProps.data)
|
||||
if (dialogProps.data.label) setNodeName(dialogProps.data.label)
|
||||
}
|
||||
|
||||
@@ -38,33 +38,51 @@ describe('useAvailableVariables', () => {
|
||||
mockState.edges = []
|
||||
})
|
||||
|
||||
it('always returns global variables (question, chat_history, file_attachment)', () => {
|
||||
it('always returns global variables (chat context + flow variables)', () => {
|
||||
const { result } = renderHook(() => useAvailableVariables('node_0'))
|
||||
|
||||
const labels = result.current.map((i) => i.label)
|
||||
// Chat Context
|
||||
expect(labels).toContain('question')
|
||||
expect(labels).toContain('chat_history')
|
||||
expect(labels).toContain('current_date_time')
|
||||
expect(labels).toContain('runtime_messages_length')
|
||||
expect(labels).toContain('loop_count')
|
||||
expect(labels).toContain('file_attachment')
|
||||
expect(result.current).toHaveLength(3)
|
||||
// Flow Variables
|
||||
expect(labels).toContain('$flow.sessionId')
|
||||
expect(labels).toContain('$flow.chatId')
|
||||
expect(labels).toContain('$flow.chatflowId')
|
||||
expect(result.current).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('returns upstream node outputs based on edges', () => {
|
||||
mockState.nodes = [makeNode('llm_0', 'llmAgentflow'), makeNode('agent_0', 'agentAgentflow')]
|
||||
mockState.edges = [{ source: 'llm_0', target: 'agent_0' }]
|
||||
|
||||
const { result } = renderHook(() => useAvailableVariables('agent_0'))
|
||||
|
||||
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
|
||||
expect(nodeOutputs).toHaveLength(1)
|
||||
expect(nodeOutputs[0].value).toBe('{{llm_0.data.instance}}')
|
||||
expect(nodeOutputs[0].label).toBe('llmAgentflow')
|
||||
})
|
||||
|
||||
it('excludes startAgentflow from node outputs', () => {
|
||||
mockState.nodes = [makeNode('start_0', 'startAgentflow'), makeNode('agent_0', 'agentAgentflow')]
|
||||
mockState.edges = [{ source: 'start_0', target: 'agent_0' }]
|
||||
|
||||
const { result } = renderHook(() => useAvailableVariables('agent_0'))
|
||||
|
||||
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
|
||||
expect(nodeOutputs).toHaveLength(1)
|
||||
expect(nodeOutputs[0].value).toBe('{{start_0.data.instance}}')
|
||||
expect(nodeOutputs[0].label).toBe('startAgentflow')
|
||||
expect(nodeOutputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not return downstream node outputs', () => {
|
||||
mockState.nodes = [makeNode('start_0', 'startAgentflow'), makeNode('agent_0', 'agentAgentflow')]
|
||||
mockState.edges = [{ source: 'start_0', target: 'agent_0' }]
|
||||
mockState.nodes = [makeNode('llm_0', 'llmAgentflow'), makeNode('agent_0', 'agentAgentflow')]
|
||||
mockState.edges = [{ source: 'llm_0', target: 'agent_0' }]
|
||||
|
||||
const { result } = renderHook(() => useAvailableVariables('start_0'))
|
||||
const { result } = renderHook(() => useAvailableVariables('llm_0'))
|
||||
|
||||
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
|
||||
expect(nodeOutputs).toHaveLength(0)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { VariableItem } from '@/atoms/SelectVariable'
|
||||
import type { FlowEdge, FlowNode } from '@/core/types'
|
||||
import type { VariableItem } from '@/atoms/VariablePicker'
|
||||
import { getAgentflowIcon } from '@/core/node-config/nodeIconUtils'
|
||||
import { getUpstreamNodes } from '@/core/utils/variableUtils'
|
||||
import { useAgentflowContext } from '@/infrastructure/store'
|
||||
|
||||
// ── Static global variables (matches original SelectVariable.jsx) ───────────
|
||||
// ── Static global variables (matches original suggestionOption.js) ───────────
|
||||
|
||||
const GLOBAL_VARIABLES: VariableItem[] = [
|
||||
{ label: 'question', description: "User's question from chatbox", category: 'Chat Context', value: '{{question}}' },
|
||||
@@ -14,41 +15,53 @@ const GLOBAL_VARIABLES: VariableItem[] = [
|
||||
category: 'Chat Context',
|
||||
value: '{{chat_history}}'
|
||||
},
|
||||
{
|
||||
label: 'current_date_time',
|
||||
description: 'Current date and time',
|
||||
category: 'Chat Context',
|
||||
value: '{{current_date_time}}'
|
||||
},
|
||||
{
|
||||
label: 'runtime_messages_length',
|
||||
description: 'Total messages between LLM and Agent',
|
||||
category: 'Chat Context',
|
||||
value: '{{runtime_messages_length}}'
|
||||
},
|
||||
{
|
||||
label: 'loop_count',
|
||||
description: 'Current loop count',
|
||||
category: 'Chat Context',
|
||||
value: '{{loop_count}}'
|
||||
},
|
||||
{
|
||||
label: 'file_attachment',
|
||||
description: 'Files uploaded from the chat when Full File Upload is enabled on the Configuration',
|
||||
description: 'Files uploaded from the chat',
|
||||
category: 'Chat Context',
|
||||
value: '{{file_attachment}}'
|
||||
},
|
||||
{ label: '$flow.sessionId', description: 'Current session ID', category: 'Flow Variables', value: '{{$flow.sessionId}}' },
|
||||
{ label: '$flow.chatId', description: 'Current chat ID', category: 'Flow Variables', value: '{{$flow.chatId}}' },
|
||||
{
|
||||
label: '$flow.chatflowId',
|
||||
description: 'Current chatflow ID',
|
||||
category: 'Flow Variables',
|
||||
value: '{{$flow.chatflowId}}'
|
||||
}
|
||||
]
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk edges backward from `nodeId` to collect all direct upstream source nodes.
|
||||
*/
|
||||
function getUpstreamNodes(nodeId: string, nodes: FlowNode[], edges: FlowEdge[]): FlowNode[] {
|
||||
const sourceIds = new Set<string>()
|
||||
for (const edge of edges) {
|
||||
if (edge.target === nodeId) {
|
||||
sourceIds.add(edge.source)
|
||||
}
|
||||
}
|
||||
return nodes.filter((n) => sourceIds.has(n.id))
|
||||
}
|
||||
|
||||
// ── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the list of variable items available for a given node.
|
||||
*
|
||||
* Matches the original SelectVariable.jsx behaviour:
|
||||
* - Global variables: question, chat_history, file_attachment
|
||||
* Matches the original suggestionOption.js behaviour:
|
||||
* - Chat context: question, chat_history, current_date_time, runtime_messages_length, loop_count, file_attachment
|
||||
* - Flow variables: $flow.sessionId, $flow.chatId, $flow.chatflowId
|
||||
* - Upstream node outputs (from edges)
|
||||
* - Flow state variables (from startAgentflow node's startState)
|
||||
*
|
||||
* Lives in the features layer so it can read from AgentflowContext.
|
||||
* The returned items are passed to the SelectVariable atom via props.
|
||||
* The returned items are passed to the VariablePicker atom via props.
|
||||
*/
|
||||
export function useAvailableVariables(nodeId: string): VariableItem[] {
|
||||
const { state } = useAgentflowContext()
|
||||
@@ -67,11 +80,14 @@ export function useAvailableVariables(nodeId: string): VariableItem[] {
|
||||
node.data.label ??
|
||||
node.data.id
|
||||
|
||||
const agentflowIcon = getAgentflowIcon(node.data.name)
|
||||
items.push({
|
||||
label: displayName,
|
||||
description: `Output from ${node.data.label ?? node.data.name}`,
|
||||
category: 'Node Outputs',
|
||||
value: `{{${node.id}.data.instance}}`
|
||||
value: `{{${node.id}.data.instance}}`,
|
||||
icon: agentflowIcon?.icon,
|
||||
iconColor: agentflowIcon?.color
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Generated
+20
-326
@@ -132,18 +132,27 @@ importers:
|
||||
'@tabler/icons-react':
|
||||
specifier: ^3.7.0
|
||||
version: 3.31.0([email protected])
|
||||
'@tiptap/core':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@tiptap/[email protected])
|
||||
'@tiptap/extension-code-block-lowlight':
|
||||
specifier: ^3.4.3
|
||||
version: 3.4.3(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/[email protected](@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)([email protected])([email protected])
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/[email protected](@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)([email protected])([email protected])
|
||||
'@tiptap/extension-mention':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^2.11.5
|
||||
version: 2.12.0(@tiptap/[email protected](@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@tiptap/[email protected](@tiptap/[email protected](@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4
|
||||
'@tiptap/react':
|
||||
specifier: ^2.11.5
|
||||
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)([email protected]([email protected]))([email protected])
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@floating-ui/[email protected])(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^2.11.5
|
||||
version: 2.12.0
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4
|
||||
'@uiw/codemirror-theme-sublime':
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.24(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])
|
||||
@@ -180,6 +189,9 @@ importers:
|
||||
reactflow:
|
||||
specifier: ^11.5.0
|
||||
version: 11.10.4(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
tippy.js:
|
||||
specifier: ^6.3.7
|
||||
version: 6.3.7
|
||||
uuid:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
@@ -8289,53 +8301,27 @@ packages:
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-3qX8oGVKFFZzQ0vit+ZolR6AJIATBzmEmjAA0llFhWk4vf3v64p1YcXcJsOBsr5scizJu5L6RYWEFatFwqckRg==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-XUC2A77YAPMJS2SqZ2S62IGcUH8gZ7cdhoWlYQb1pR4ZzXFByeKDJPxfYeAePSiuI01YGrlzgY2c6Ncx/DtO0A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-lAUtoLDLRc5ofD2I9MFY6MQ7d1qBLLqS1rvpwaPjOaoQb/GPVnaHj9qXYG0SY9K3erMtto48bMFpAcscjZHzZQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-DYijoE0igV0Oi+ZppFsp2UrQsM/4HZtmmpD78BJM9zfCbd1YvAUIxmzmXr8uqU18OHd1uQy+/zvuNoUNYyw67g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-EXywPlI8wjPcAb8ozymgVhjtMjFrnhtoyNTy8ZcObdpUi5CdO9j892Y7aPbKe5hLhlDpvJk7rMfir4FFKEmfng==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-YTCjztB8MaIpwyxFYr81H4+LdKCq1VlaSXQyrPdB44mVdhhRqc46BYQb8/B//XE3UIu3X2QWFjwrqRlUq6vUiw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==}
|
||||
peerDependencies:
|
||||
@@ -8350,64 +8336,27 @@ packages:
|
||||
highlight.js: ^11
|
||||
lowlight: ^2 || ^3
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-d3vEhv2cLDdWL2unqRkF0LkGEDEm/pJCWZwSjqzYFe3vrTEsTExorpwZoVV8i9uX/DPG4AdM+mf3hLK9L5FZoA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.4.3
|
||||
'@tiptap/extension-code-block': ^3.4.3
|
||||
'@tiptap/pm': ^3.4.3
|
||||
highlight.js: ^11
|
||||
lowlight: ^2 || ^3
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-1D7cYAjgxEFHdfC/35Ooi4GqWKB5sszbW8iI7N16XILNln26xb0d5KflXqYrwr9CN/ZnZoCl2o6YsP7xEObcZA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-R7RaS+hJeHFim7alImQ9L9CSWSMjWXvz0Ote568x9ea5gdBGUYW8PcH+5a91lh8e1XGYWBM12a8oJZRyxg/tQA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-sA1Q+mxDIv0Y3qQTBkYGwknNbDcGFiJ/fyAFholXpqbrcRx3GavwR/o0chBdsJZlFht0x7AWGwUYWvIo7wYilA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-zcZSOXFj+7LVnmdPWTfKr5AoxYIzFPFlLJe35AdTQC5IhkljLn1Exct8I30ZREojX/00hKYsO7JJmePS6TEVlQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-BYpyZx/56KCDksWuJJbhki/uNgt9sACuSSZFH5AN1yS1ISD+EzIxqf6Pzzv8QCoNJ+KcRNVaZsOlOFaJGoyzag==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-AaPTFhoO8DBIElJyd/RTVJjkctvJuL+GHURX0npbtTxXq5HXbebVwf2ARNR7jMd/GThsmBaNJiGxZg4A2oeDqQ==}
|
||||
peerDependencies:
|
||||
@@ -8415,60 +8364,27 @@ packages:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-k8ji5v9YKn7bNjo8UtI9hEfXfl4tKUp1hpJOEmUxGJQa3LIwrwSbReupUTnHszGQelzxikS/l1xO9P0TIGwRoA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-08MNS2PK5DzdnAfqXn4krmJ/xebKmWpRpYqqN5EM8AvetYKlAJyTVSpo0ZUeGbZ3EZiPm9djgSnrLqpFUDjRCg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-9DfES4Wd5TX1foI70N9sAL+35NN1UHrtzDYN2+dTHupnmKir9RaMXyZcbkUb4aDVzYrGxIqxJzHBVkquKIlTrw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-+B9CAf2BFURC6mQiM1OQtahVTzdEOEgT/UUNlRZkeeBc0K5of3dr6UdBqaoaMAefja3jx5PqiQ7mhUBAjSt6AA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-Vi2+6RIehDSpoJn/7PDuOieUj7W7WrEb4wBxK9TG8PDscihR0mehhhzm/K2xhH4TN48iPJGRsjDFrFjTbXmcnw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-JKcXK3LmEsmxNzEq5e06rPUGMRLUxmJ2mYtBY4NlJ6yLM9XMDljtgeTnWT0ySLYmfINSFTkX4S7WIRbpl9l4pw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==}
|
||||
peerDependencies:
|
||||
@@ -8480,11 +8396,6 @@ packages:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-4YwZooC8HP+gPxs6YrkB1ayggyYbgVvJx/rWBT6lKSW2MVVg8QXi1zAcSI3MhIhHmqDysXXFPL8JURlbeGjaFA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==}
|
||||
peerDependencies:
|
||||
@@ -8508,57 +8419,26 @@ packages:
|
||||
'@tiptap/pm': ^3.20.4
|
||||
'@tiptap/suggestion': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-1ys0e/oqk09oXxrB1WzAx5EntK/QreObG/V1yhgihGm429fxHMsxzIYN6dKAYxx0YOPQG7qEZRrrPuWU70Ms7g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-QNK5cgewCunWFxpLlbvvoO1rrLgEtNKxiY79fctP9toV+e59R+1i1Q9lXC1O5mOfDgVxCb6uFDMsqmKhFjpPog==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-K7irDox4P+NLAMjVrJeG72f0sulsCRYpx1Cy4gxKCdi1LTivj5VkXa6MXmi42KTCwBu3pWajBctYIOAES1FTAA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-GB0KWtqm83YHG8cnqBLijvUBm+xvLfQHDfFRRH2fb3EzH3eIsM9jKRC31ADT27RSV1zVpHMFGcP3/pWpdrN1Lw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-nBaa5YtBsLJPZFfSs36sBz4Zgi/c8b3MsmS/Az8uXaHb0R9yPewOVUMDIQbxMct8SXUlIo9VtKlOL+mVJ3Nkpw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-Pxwt23ZlvbQUahV0PvHy8Ej6IAuKR1FvHobUvwP3T8AiY7hob66fWRe7tQbESzSAzm5Vv2xkvyHeU8vekMTezA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-0ytN9V1tZYTXdiYDQg4FB2SQ56JAJC9r/65snefb9ztl+gZzDrIvih7CflHs1ic9PgyjexfMLeH+VzuMccNyZw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==}
|
||||
peerDependencies:
|
||||
@@ -8581,20 +8461,9 @@ packages:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-TNzVwpeNzFfHAcYTOKqX9iU4fRxliyoZrCnERR+RRzeg7gWrXrCLubQt1WEx0sojMAfznshSL3M5HGsYjEbYwA==}
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==}
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-D+PR+4kJO9h8AB/7XyQ/Anw8tqeS2ecv5QemBOCHi9JlMAjytauUrj6IfFBO9RbsCowlBjW5GnSpFhzpk2Gghg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-1B8iWsHWwb5TeyVaUs8BRPzwWo4PsLQcl03urHaz0zTJ8DauopqvxzV3+lem1OkzRHn7wnrapDvwmIGoROCaQw==}
|
||||
peerDependencies:
|
||||
@@ -8605,9 +8474,6 @@ packages:
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-wlcEEtexd6u0gbR311/OFZnbtRWU97DUsY6/GsSQzN4rqZ7Ra6YbfHEN5Lutu+I/anomK8vKy8k9NyvfY5Hllg==}
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
resolution: {integrity: sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==}
|
||||
|
||||
@@ -30515,36 +30381,18 @@ snapshots:
|
||||
dependencies:
|
||||
'@testing-library/dom': 9.3.4
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
tippy.js: 6.3.7
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.3
|
||||
@@ -30552,10 +30400,6 @@ snapshots:
|
||||
'@tiptap/pm': 3.20.4
|
||||
optional: true
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
@@ -30568,60 +30412,23 @@ snapshots:
|
||||
highlight.js: 11.11.1
|
||||
lowlight: 3.3.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))(@tiptap/[email protected])([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/extension-code-block': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
highlight.js: 11.11.1
|
||||
lowlight: 3.3.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
tippy.js: 6.3.7
|
||||
|
||||
'@tiptap/[email protected](@floating-ui/[email protected])(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.3
|
||||
@@ -30629,50 +30436,23 @@ snapshots:
|
||||
'@tiptap/pm': 3.20.4
|
||||
optional: true
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
@@ -30683,10 +30463,6 @@ snapshots:
|
||||
'@tiptap/pm': 3.20.4
|
||||
linkifyjs: 4.3.2
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
@@ -30706,47 +30482,22 @@ snapshots:
|
||||
'@tiptap/pm': 3.20.4
|
||||
'@tiptap/suggestion': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
@@ -30766,27 +30517,6 @@ snapshots:
|
||||
'@tiptap/pm': 3.20.4
|
||||
marked: 17.0.4
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.3.0
|
||||
prosemirror-collab: 1.3.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.3.2
|
||||
prosemirror-history: 1.4.1
|
||||
prosemirror-inputrules: 1.5.0
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-markdown: 1.13.2
|
||||
prosemirror-menu: 1.2.5
|
||||
prosemirror-model: 1.25.1
|
||||
prosemirror-schema-basic: 1.2.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-tables: 1.7.1
|
||||
prosemirror-trailing-node: 3.0.0([email protected])([email protected])([email protected])
|
||||
prosemirror-transform: 1.10.4
|
||||
prosemirror-view: 1.39.2
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.3.0
|
||||
@@ -30808,18 +30538,6 @@ snapshots:
|
||||
prosemirror-transform: 1.10.4
|
||||
prosemirror-view: 1.39.2
|
||||
|
||||
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/extension-bubble-menu': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/extension-floating-menu': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/pm': 2.12.0
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
fast-deep-equal: 3.1.3
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0([email protected])
|
||||
use-sync-external-store: 1.2.0([email protected])
|
||||
|
||||
'@tiptap/[email protected](@floating-ui/[email protected])(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
@@ -30837,30 +30555,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@floating-ui/dom'
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.12.0(@tiptap/[email protected])
|
||||
'@tiptap/extension-blockquote': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-bold': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-bullet-list': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-code': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-code-block': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/extension-document': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-dropcursor': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/extension-gapcursor': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/extension-hard-break': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-heading': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-history': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/extension-horizontal-rule': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/extension-italic': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-list-item': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-ordered-list': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-paragraph': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-strike': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-text': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/extension-text-style': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
|
||||
'@tiptap/pm': 2.12.0
|
||||
|
||||
'@tiptap/[email protected]':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/[email protected])
|
||||
|
||||
Reference in New Issue
Block a user