Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
201d5b6
feat(core): publish loop containers and validate their scope and conf…
wanglongan587 Oct 25, 2026
11b300a
feat(core): execute loop rounds and loopExit in the scheduler
wanglongan587 Oct 25, 2026
ca12ae7
feat(workflows): add a Loop from the palette as one atomic starter group
wanglongan587 Oct 25, 2026
5aefaa3
feat(workflows): edit Loop rounds and import the loopExit marker
wanglongan587 Oct 25, 2026
aa6ddc1
test(core): pin the palette's loop starter group against the publish …
wanglongan587 Oct 25, 2026
d2370ea
fix(core): cancel a running loop when its run is cancelled
wanglongan587 Oct 25, 2026
2da4919
refactor(workflows): share one variable type menu between globals and…
wanglongan587 Oct 25, 2026
54f5c08
feat(workflows): edit Loop carried variables and named outputs
wanglongan587 Oct 25, 2026
2cc9af5
feat(workflows): edit a Loop's exit condition in the inspector
wanglongan587 Oct 25, 2026
ba54322
feat(core): publish and execute the variable aggregator node
wanglongan587 Oct 25, 2026
5f8325e
feat(workflows): author the variable aggregator node
wanglongan587 Oct 25, 2026
41008da
feat(workflows): integrate loop/loopExit and aggregator nodes
wanglongan587 Oct 25, 2026
950805c
feat(workflows): offer every condition operator the engine evaluates
wanglongan587 Oct 25, 2026
060df28
feat(workflows): reorder aggregator candidates in the inspector
wanglongan587 Oct 25, 2026
99d2b20
refactor(core): normalize container membership to containerId or pare…
wanglongan587 Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions frontend/src/features/workflows/aggregator-translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Messages for the variable aggregator node.
*
* They live beside the feature's main bundle for the same reason the history and launch-field
* bundles do: the group is self-contained — the node's palette identity and the ordered candidate
* list — and the main bundle is held to a line budget. `translations.ts` spreads both locales in.
*/
export const aggregatorTranslations = {
'zh-CN': {
'workflows.node.aggregator.label': '变量聚合',
'workflows.node.aggregator.description': '按顺序取第一个已赋值的候选变量',
'workflows.aggregator.field.variables': '候选变量',
'workflows.aggregator.field.candidate': '候选',
'workflows.aggregator.addCandidate': '添加候选',
'workflows.aggregator.removeCandidate': '删除候选',
'workflows.aggregator.moveCandidateUp': '上移候选',
'workflows.aggregator.moveCandidateDown': '下移候选',
'workflows.aggregator.selectorPlaceholder': '节点.变量,例如 writer.output',
},
'en-US': {
'workflows.node.aggregator.label': 'Variable aggregator',
'workflows.node.aggregator.description': 'Pass through the first assigned candidate, in order',
'workflows.aggregator.field.variables': 'Candidates',
'workflows.aggregator.field.candidate': 'Candidate',
'workflows.aggregator.addCandidate': 'Add candidate',
'workflows.aggregator.removeCandidate': 'Remove candidate',
'workflows.aggregator.moveCandidateUp': 'Move candidate up',
'workflows.aggregator.moveCandidateDown': 'Move candidate down',
'workflows.aggregator.selectorPlaceholder': 'node.variable, for example writer.output',
},
}
12 changes: 12 additions & 0 deletions frontend/src/features/workflows/editor/node-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import {
ArrowRight,
Boxes,
Braces,
CornerDownRight,
GitBranch,
GitMerge,
Layers,
Merge,
Play,
Repeat,
UserCheck,
Expand Down Expand Up @@ -38,6 +40,11 @@ const NODE_METADATA: Record<WorkflowNodeKind, WorkflowNodeMetadata> = {
icon: GitBranch,
tone: 'bg-amber-500/12 text-amber-700 dark:text-amber-400',
},
aggregator: {
kind: 'aggregator',
icon: Merge,
tone: 'bg-orange-500/12 text-orange-700 dark:text-orange-400',
},
tool: { kind: 'tool', icon: Braces, tone: 'bg-cyan-500/12 text-cyan-700 dark:text-cyan-400' },
junction: {
kind: 'junction',
Expand All @@ -50,6 +57,11 @@ const NODE_METADATA: Record<WorkflowNodeKind, WorkflowNodeMetadata> = {
icon: Repeat,
tone: 'bg-indigo-500/12 text-indigo-700 dark:text-indigo-400',
},
loopExit: {
kind: 'loopExit',
icon: CornerDownRight,
tone: 'bg-indigo-500/12 text-indigo-700 dark:text-indigo-400',
},
iteration: {
kind: 'iteration',
icon: Layers,
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/features/workflows/editor/use-workflow-draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,28 @@ function requireNode(nodes: WorkflowCanvasNode[], id: string): WorkflowCanvasNod
}

describe('useWorkflowDraft', () => {
it('adds a Loop as one atomic group with its starter body', () => {
// A bare Loop saves but never publishes: the body needs a Start. The palette entry therefore
// emits the container, its Start, its starter Agent and the internal edge together.
const { result } = setup(fixtureWithGraph([outputNode()]))

act(() => {
result.current.addNode('loop', { x: 0, y: 0 })
})

expect(
result.current.nodes.map((node) => [node.id, node.data.kind, node.data.containerId]),
).toEqual([
['output-1', 'output', undefined],
['loop-1', 'loop', undefined],
['loop-1-start', 'start', 'loop-1'],
['loop-1-agent', 'agent', 'loop-1'],
])
expect(result.current.edges.map((edge) => [edge.source, edge.target])).toEqual([
['loop-1-start', 'loop-1-agent'],
])
})

it('adds a member through the entry seam, widening the frame and wiring the entry edge', () => {
const { result } = setup(draftFixture())

Expand Down
11 changes: 11 additions & 0 deletions frontend/src/features/workflows/editor/use-workflow-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type WorkflowIterationInsertion,
} from '@/features/workflows/runtime/iteration-graph'
import {
createWorkflowLoopGroup,
createWorkflowNode,
nextWorkflowNodeSequence,
type WorkflowNodeTranslator,
Expand Down Expand Up @@ -300,6 +301,16 @@ function appendNode(
kind,
current.nodes.map((node) => node.id),
)
if (kind === 'loop') {
// A Loop without a body is a saveable but unrunnable container; the factory emits the body
// start, a starter agent and the round contract together so the palette entry is executable.
const group = createWorkflowLoopGroup({ kind, sequence, position, translate })
return {
...current,
nodes: [...current.nodes, ...group.nodes],
edges: [...current.edges, ...group.edges],
}
}
const node = createWorkflowNode({ kind, sequence, position, translate })
return { ...current, nodes: [...current.nodes, node] }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { WorkflowNodeData } from '@/features/workflows/runtime/types'
import { AggregatorNodeFields } from '@/features/workflows/editor/workflow-aggregator-fields'
import { renderWithProviders } from '@/test/render'

/** The node the panel edits; candidates are the only thing it reads. */
function aggregatorData(variables: string[][]): WorkflowNodeData {
return {
kind: 'aggregator',
title: '变量聚合',
description: '',
aggregatorConfig: { variables },
}
}

/**
* Renders the panel and tracks the latest node it published.
*
* `published.data` is what a real editor would persist, so each assertion reads the shape the save
* path would write instead of a captured mock call.
*/
function renderPanel(variables: string[][]) {
const published = { data: aggregatorData(variables) }
const onChange = (next: WorkflowNodeData): void => {
published.data = next
}
const view = renderWithProviders(
<AggregatorNodeFields data={published.data} onChange={onChange} />,
)
return { published, onChange, view }
}

describe('AggregatorNodeFields', () => {
it('adds a candidate and writes its root selector', () => {
const { published, onChange, view } = renderPanel([])

fireEvent.click(screen.getByRole('button', { name: '添加候选' }))
expect(published.data.aggregatorConfig?.variables).toEqual([[]])

view.rerender(<AggregatorNodeFields data={published.data} onChange={onChange} />)
fireEvent.change(screen.getByLabelText('候选 1'), { target: { value: 'writer.output' } })
expect(published.data.aggregatorConfig?.variables).toEqual([['writer', 'output']])
})

it('moves a candidate up to change the published priority', () => {
const { published } = renderPanel([
['a', 'output'],
['b', 'output'],
])

fireEvent.click(screen.getByRole('button', { name: '上移候选 2' }))

expect(published.data.aggregatorConfig?.variables).toEqual([
['b', 'output'],
['a', 'output'],
])
})

it('keeps the declared order when a candidate is removed', () => {
const { published } = renderPanel([
['a', 'output'],
['b', 'output'],
])

fireEvent.click(screen.getByRole('button', { name: '删除候选 1' }))

expect(published.data.aggregatorConfig?.variables).toEqual([['b', 'output']])
})
})
112 changes: 112 additions & 0 deletions frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useTranslation } from 'react-i18next'
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields'
import {
selectorToText,
textToSelector,
} from '@/features/workflows/editor/workflow-variable-select'
import type { WorkflowNodeData } from '@/features/workflows/runtime/types'

/** Props every Aggregator panel component takes: the node data and the writer for the whole node. */
export interface AggregatorFieldsProps {
data: WorkflowNodeData
onChange: (data: WorkflowNodeData) => void
}

/**
* Edits the aggregator's ordered candidates.
*
* Order is the contract: the first assigned candidate is the one the node publishes, so the panel
* never reorders the list on its own and exposes the position as a plain number. A selector is
* `node.root` text until the shared picker replaces it; the publish gate refuses an undeclared or
* nested selector, so a typo is caught before the graph runs.
*/
export function AggregatorNodeFields({ data, onChange }: AggregatorFieldsProps) {
const { t } = useTranslation()
const config = data.aggregatorConfig
if (config === undefined) {
return null
}
const update = (variables: string[][]): void => {
onChange({ ...data, aggregatorConfig: { variables } })
}
// Order is the published priority, so moving a candidate is a first-class edit rather than a
// re-sort the panel could infer.
const move = (from: number, to: number): void => {
const next = [...config.variables]
const moved = next.splice(from, 1)[0]
if (moved === undefined) {
return
}
next.splice(to, 0, moved)
update(next)
}
return (
<InspectorField
label={t('workflows.aggregator.field.variables')}
htmlFor="workflow-aggregator-variables"
>
<div className="space-y-2" id="workflow-aggregator-variables">
{/* oxlint-disable react/no-array-index-key -- a candidate has no stable identity: its selector is the editable value, so a content key collides on two blank rows. */}
{config.variables.map((selector, index) => (
<div key={`aggregator-candidate-${index}`} className="flex items-center gap-2">
<span className="w-4 shrink-0 text-[11px] text-muted-foreground">{index + 1}</span>
<Input
aria-label={`${t('workflows.aggregator.field.candidate')} ${index + 1}`}
placeholder={t('workflows.aggregator.selectorPlaceholder')}
value={selectorToText(selector)}
onChange={(event) =>
update(
config.variables.map((current, i) =>
i === index ? textToSelector(event.target.value) : current,
),
)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`${t('workflows.aggregator.moveCandidateUp')} ${index + 1}`}
disabled={index === 0}
onClick={() => move(index, index - 1)}
>
<ArrowUp />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`${t('workflows.aggregator.moveCandidateDown')} ${index + 1}`}
disabled={index === config.variables.length - 1}
onClick={() => move(index, index + 1)}
>
<ArrowDown />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`${t('workflows.aggregator.removeCandidate')} ${index + 1}`}
onClick={() => update(config.variables.filter((_, i) => i !== index))}
>
<Trash2 />
</Button>
</div>
))}
{/* oxlint-enable react/no-array-index-key */}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => update([...config.variables, []])}
>
<Plus />
{t('workflows.aggregator.addCandidate')}
</Button>
</InspectorField>
)
}
70 changes: 70 additions & 0 deletions frontend/src/features/workflows/editor/workflow-choice-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { workflowChoiceLabel, type WorkflowChoice } from '@/features/workflows/runtime/capabilities'
import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields'
import { LocalizedSelectValue } from '@/features/workflows/editor/workflow-variable-select'
import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator'

/** A select whose menu text and closed-state label both come from one choice list. */
export function WorkflowChoiceSelect({
id,
value,
options,
disabled,
ariaLabel,
className,
onChange,
}: {
id: string
value: string
options: readonly WorkflowChoice[]
disabled?: boolean
ariaLabel?: string
className?: string
onChange: (value: string | null) => void
}) {
const translate = useWorkflowTranslator()
return (
<Select value={value} disabled={disabled} onValueChange={onChange}>
<SelectTrigger id={id} aria-label={ariaLabel} className={className}>
<LocalizedSelectValue options={options} value={value} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{workflowChoiceLabel(option, translate)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

/** A {@link WorkflowChoiceSelect} wrapped in the inspector's label block. */
export function LocalizedChoiceSelect({
id,
label,
value,
options,
disabled,
onChange,
}: {
id: string
label: string
value: string
options: readonly WorkflowChoice[]
disabled?: boolean
onChange: (value: string | null) => void
}) {
return (
<InspectorField label={label} htmlFor={id}>
<WorkflowChoiceSelect
id={id}
value={value}
options={options}
{...(disabled === undefined ? {} : { disabled })}
className="w-full"
onChange={onChange}
/>
</InspectorField>
)
}
Loading
Loading