Workflow revamp

This commit is contained in:
Pavel committed 2026-09-27 21:31:01 +04:00
1 parent d4c97a49ce
commit 4070b03053
43 files changed
+3559 -2010

No files matched your search

+87 -11
View File
@@ -228,7 +228,8 @@ field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`)
row never holds more than three buttons and fits a phone, where the main
one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot
spans the stacked row below `sm`). The workflow builder's toolbar has no
title row and keeps its ⋯ at the row's end. The
title row and keeps its ⋯ at the row's end, holding Edit details, Access
details and Delete (see Workflow builder). The
agent's Delete is in Overview's danger zone; only the workflow builder's
toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is
`agents/components/AgentPageToolbar` (see Page chrome). A row's common
@@ -255,8 +256,9 @@ rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal
Padding-free tabs (a sub-nav with `gap-6`) add `size="inline"`, which keeps
4px above the line; the row draws the 1px baseline, and `-mb-px` lays the
underline over it. Its one use is `agents/AgentPageHeader.tsx`, the workflow
builder's fixed toolbar: a breadcrumb and the Overview / Logs / Schedules
tabs in a `<nav>`, the others `<Link>`s and the current one a `<span
builder's fixed toolbar: a breadcrumb, the status Badge and the Overview /
Logs / Schedules tabs in a `<nav>` (no tabs until the workflow has an
id), the others `<Link>`s and the current one a `<span
aria-current="page">` with `data-active`. The
agent section pages switch with the section sidebar (the phone menu below
`lg`) and carry no pill row; the agent tile's ⋯ also opens Logs. Tabs that switch a panel in place are
@@ -556,8 +558,10 @@ controls in a row, a loading or error line in place of the field. Controls
whose label sits beside them (Switch, Checkbox, radio) use `SettingRow` or
an inline `Label`, not FormField. An editing surface that fills its area
(the chat composer, the chunk and wiki editors, editing a sent question) and
repeated rows in a list (workflow expressions) have no visible label and
must have an `aria-label`. Never hand-build a label above a field, a
bare repeated rows in a list have no visible label and must have an
`aria-label`. A row box that is a small form (a workflow condition case or
state assignment) is not a bare row: each of its fields has a floating
label. Never hand-build a label above a field, a
hand-positioned floating label, or a `div.flex-col` + `Label` + `<p>` stack.
### SettingRow (`ui/setting-row.tsx`)
@@ -707,7 +711,10 @@ from device, Import agent, Import API specification), `compact` (one row, a
file field among other fields),
`tile` (a square beside a form's fields, the agent's avatar: 64px and
icon-only on a phone beside the Name field, 88px with a one-word title from
`sm` beside Name and Description; no description). `FileUpload size="tile"` fills the tile
`sm` beside Name and Description; no description). `tileSize="fixed"` keeps
it 64px and icon-only at every width, for a tile in a narrow drawer (the
workflow details sheet), where the breakpoint follows the window rather than
the drawer. `FileUpload size="tile"` fills the tile
with the picked image, or with `currentImage` (the saved one) until then.
Props: `onDrop`, `accept`, `multiple`, `maxFiles`, `maxSize`, `disabled`,
`title`, `description`, `icon`, `error`, `validator` (react-dropzone's
@@ -960,6 +967,74 @@ grid inside a Modal keeps its own column counts, because breakpoints follow
the window, not the dialog.
Skeleton mirrors follow the grid they stand in for.
### Workflow builder (`agents/workflow/`)
The toolbar is `AgentPageHeader` on `bg-background` with a `border-b`. Its
current crumb is the agent's `Avatar` (circle, 20px image, the robot when
there is none), the name (`truncate`, `max-w-[24ch]`) and a muted
`ChevronDown` in a `ghost sm` Button, the phone top bar's title recipe; it
opens the workflow details. The status Badge follows (`success` Published,
`neutral` Draft until the first save), then the tabs. On the right: "Unsaved
changes" as muted `text-sm` meta while there are any, Preview, Save, and the
⋯ (`ActionMenu size="toolbar"`): Edit details first (the same drawer, for
anyone who doesn't try the name), then Access details and Delete once the
workflow is saved.
The workflow details are a right `Sheet size="default"`
(`components/WorkflowDetailsSheet.tsx`), built like AgentPreviewSheet (header,
`Separator`, one scrolling body) with the classic Basics phone layout: a
`subtle lg` Basics panel with `FileUpload size="tile" tileSize="fixed"` beside
the Name pill and Description across the row, then an Advanced panel with the
prompt-override `SettingRow`. Its footer is a `ghost lg pill` Cancel and a
`default lg pill` Save after a `Separator`. The sheet edits a copy: Cancel,
the X, Escape and the scrim drop the edits; Save is disabled until something
changes, then saves the workflow and closes, or stays open with a destructive
`Alert` listing the errors.
The canvas is `bg-muted`; the node palette to its left is a `bg-background`
rail with `border-r` (`NodePalette.tsx`), and each palette pill is a
`<button>` that is also `draggable`: drag it onto the canvas, or click (or
press Enter) to add the node beside the selected one, or in the middle of the
view. Each pill's icon is a `size-8 rounded-md` tinted square, like the
settings panel's header (the pill is round, its icon is not; the canvas
nodes keep their `size-10` circles). Pills hover to `bg-accent` only, with
the focus ring, and a muted `text-xs` line under the groups says so.
- **One tone per node type**: `nodeTones.ts` (`NODE_TONES`, `nodeToneClass`)
is read by the palette, every canvas node and the settings panel's header.
Agent is the brand soft fill (`bg-secondary text-secondary-foreground`),
Start and End `success`, Note and If / Else `warning`, Set State and Code
`info`, each `bg-<tone>/10 text-<tone>`. End is never `destructive`.
- **Selected node**: `border-primary ring-3 ring-ring/50`, the focus-ring
look, with no scale (the Note node: `border-warning ring-3
ring-warning/50`). Node meta is translated and muted (the agent type · the
model's display name, from `WorkflowModelsContext`); an output variable is
an `ArrowRight` then the name in `font-mono`.
- **Canvas controls**: one `bg-card border rounded-full p-1` strip at the
bottom left (`CanvasControls.tsx`, no shadow): Undo, Redo, a vertical
`Separator`, Zoom out, the zoom level, Zoom in and Fit view, each
`ghost-muted icon-sm pill` with a top tooltip. React Flow's own
`<Controls />` and attribution are not shown.
- **Node settings** dock at the canvas's right edge (`panels/NodePanel.tsx`):
a `w-96 border-l bg-background` column at full height whose body is the one
scroller, with no shadow or `z-*`. The header holds the type's `size-8
rounded-md` icon square, the node's title with a `neutral` type Badge (only
once the title differs from the type's name), the
id in `font-mono text-xs` with a `CopyButton`, an `ActionMenu
size="toolbar"` (Duplicate, Delete node; none on Start) and the close
button. Start and End have no Title field: their body is one muted
`text-sm` line saying what the node does. The body is `flex flex-col gap-6
p-4`, so its fields pass
`labelSurface="background"` and its row boxes (a condition case, a state
assignment) are `Card variant="subtle" padding="sm"`. The agent node's
fields are grouped by `SectionHeader size="xs" as="h3"` (Model, Prompt,
Knowledge, Output) with an Advanced settings `link sm` disclosure that
opens on its own when one of its settings is set. The column is narrow, so
every field stacks at full width (`flex flex-col gap-5`), Agent type and
Model included; no two-up grids. A row box's title is `SectionHeader
size="xs" as="h4"`. In an If / Else case's Simple mode, the operator Select
and Value each carry a floating label (Operator, Value).
### Accordion (`ui/accordion.tsx`)
`AccordionTrigger` carries its own inset and type (`px-4 py-3 text-sm
@@ -1205,7 +1280,8 @@ bubble) and `4xl` the answer's source cards and the landing page's model
picker; app chrome stops at `2xl`. Skeleton bars: the default. Tinted icon squares:
`rounded-md` at `size-7|8`, `rounded-xl` at `size-12|14`, `rounded-2xl` at
`size-20`. `rounded-full` only for pills (`shape="pill"` controls and
fields), avatars, status dots and the workflow palette pills.
fields), avatars, status dots, the workflow palette pills (not their icon
squares) and the canvas nodes' icon circles.
## Motion
@@ -1271,9 +1347,9 @@ shadow-lg` in both themes: the knob is white on any track, and a white knob
`bg-input`. Accordions are
panels and have none. Outside `ui/` the only shadows are: the workflow
canvas nodes (`shadow-md`, `hover:shadow-lg`: a node lifts off the canvas
while you drag it); the workflow builder's node-settings panel and its
publish-error panel, which float over the canvas at popover elevation
(`shadow-md`, `z-20`); the chunk viewer's and file tree's search result
while you drag it); the workflow builder's publish-error panel, which
floats over the canvas at popover elevation (`shadow-md`, `z-20`); the
chunk viewer's and file tree's search result
lists (`shadow-md`, in-page `z-20`, their results are `CommandItem`s inside
the search frame); the sliding panel in `navigation/SidebarLevel.tsx`,
which casts a horizontal shadow while it slides; and the Hero model
@@ -1320,7 +1396,7 @@ list stays reviewable.
| `Hero.tsx` | `shadcn/no-restyle` | The landing page's demo cards are `Button outline lg pill`, but each is a two-line pill (a title over a clamped 12px query), so it undoes lg's height, the base's one-row layout, weight and nowrap: `h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal`. One disable. |
| `Navigation.tsx`, `conversation/ConversationTile.tsx` | `shadcn/no-restyle` | A sidebar row whose link has sibling buttons (an agent's pin, a conversation's menu and rename Save / Cancel) keeps its fill while the pointer is on a sibling or the menu is open (`group-hover:bg-sidebar-accent`, `bg-sidebar-accent`), and `pr-10` keeps the label clear of the buttons. ConversationTile's `cn(...)` needs a `/* eslint-disable */` … `/* eslint-enable */` pair. |
| `admin/Usage.tsx` | `shadcn/no-restyle` | The Top users id is a `link inline` Button inside a mono table cell; it keeps the cell's type and wraps (`font-mono text-xs font-normal whitespace-normal text-left`). One disable. |
| `agents/workflow/WorkflowBuilder.tsx` | `shadcn/no-restyle` | The "Learn more" links in the Set state and Condition nodes' 12px hints keep the sentence's size and weight (`text-xs font-normal` on `link inline`). Two disables. |
| `agents/workflow/panels/ConditionPanel.tsx` | `shadcn/no-restyle` | The "Learn more" link in the Condition node's Advanced-mode 12px hint keeps the sentence's size and weight (`text-xs font-normal` on `link inline`). One disable; the Set state node's link sits in its `text-sm` intro and needs none. |
| `components/MessageInput.tsx` | `shadcn/no-restyle` | The queued-send Cancel is a `link inline` inside the composer's 12px status line, so it takes the line's size (`text-xs`). One disable, beside the send button's. |
| `settings/PersonalAccessTokens.tsx` | `shadcn/no-restyle` | Token scope chips are identifiers, so the `neutral` Badge is set in mono (`font-mono`). One disable. |
| `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | A step's state changes (keys and values the app serialised) are `neutral` Badges set in mono (`font-mono`), like token scopes. One disable. |
@@ -53,4 +53,52 @@ describe('AgentPageHeader sub-nav', () => {
expect(tabs[0].tagName).toBe('A');
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
});
it('makes the current crumb a button with the avatar and a chevron that opens the details', () => {
const onNameClick = vi.fn();
act(() => {
root.render(
<MemoryRouter>
<AgentPageHeader
agentId="a1"
agentName="Helpdesk Triage"
currentPage="overview"
onNameClick={onNameClick}
status={<span data-testid="status">Published</span>}
/>
</MemoryRouter>,
);
});
const crumb = container.querySelector(
'button[aria-haspopup="dialog"]',
) as HTMLButtonElement;
expect(crumb).not.toBeNull();
expect(crumb.getAttribute('data-variant')).toBe('ghost');
expect(crumb.getAttribute('data-size')).toBe('sm');
expect(crumb.querySelector('img')).not.toBeNull();
expect(crumb.querySelector('.lucide-chevron-down')).not.toBeNull();
expect(crumb.textContent).toContain('Helpdesk Triage');
expect(container.querySelector('[data-testid="status"]')).not.toBeNull();
act(() => crumb.click());
expect(onNameClick).toHaveBeenCalledTimes(1);
});
it('hides the tabs for a workflow that has no id yet', () => {
act(() => {
root.render(
<MemoryRouter>
<AgentPageHeader
agentName="New workflow"
currentPage="overview"
onNameClick={vi.fn()}
/>
</MemoryRouter>,
);
});
expect(
container.querySelector(
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
),
).toBeNull();
});
});
+114 -64
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { ChevronDown } from 'lucide-react';
import { type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@@ -10,6 +11,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -36,14 +38,26 @@ type AgentPageHeaderProps = {
* (e.g. the workflow builder's fixed toolbar) to avoid a double rule.
*/
inline?: boolean;
/** The agent's avatar URL; the robot is drawn when it's empty. */
agentImage?: string;
/**
* Makes the current crumb a button (avatar, name, chevron) that opens the
* agent's details. Only with `currentPage="overview"`.
*/
onNameClick?: () => void;
/** A status Badge placed after the crumbs. */
status?: ReactNode;
};
/**
* The workflow builder's toolbar chrome: a Breadcrumb (`Agents > <agent
* name> > <current page>`) and underline tab links to the agent's Overview,
* Logs and Schedules. The builder is full-screen with no sidebar, so it needs
* its own way between them. Section pages use `components/AgentPageToolbar`
* and the sidebar (or `SectionPills` on a phone) instead.
* name> > <current page>`), an optional status Badge, and underline tab links
* to the agent's Overview, Logs and Schedules (hidden until the agent has an
* id). The builder is full-screen with no sidebar, so it needs its own way
* between them. With `onNameClick` the current crumb is the agent's avatar,
* name and a chevron in a `ghost sm` Button that opens the details, like the
* phone top bar's chat title. Section pages use
* `components/AgentPageToolbar` and the sidebar instead.
*/
export default function AgentPageHeader({
agentId,
@@ -52,6 +66,9 @@ export default function AgentPageHeader({
currentPage,
className,
inline = false,
agentImage,
onNameClick,
status,
}: AgentPageHeaderProps) {
const { t } = useTranslation();
@@ -86,83 +103,116 @@ export default function AgentPageHeader({
return (
<div
className={cn(
'flex flex-col gap-3 md:flex-row md:items-baseline md:gap-6',
// The builder only renders from lg, so one row.
'flex min-w-0 items-center gap-6',
className,
)}
>
<Breadcrumb className="shrink-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={AGENTS_MANAGE_ROOT}>
{t('agents.pageHeader.crumbs.agents')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
{currentPage === 'overview' ? (
<BreadcrumbPage title={displayName} className="w-[16ch]">
{displayName}
</BreadcrumbPage>
) : (
<div className="flex min-w-0 items-center gap-2">
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={editPath} className="max-w-[40ch] truncate">
{displayName}
<Link to={AGENTS_MANAGE_ROOT}>
{t('agents.pageHeader.crumbs.agents')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
{currentPage === 'overview' && onNameClick ? (
<Button
type="button"
variant="ghost"
size="sm"
aria-haspopup="dialog"
onClick={onNameClick}
// Button is shrink-0; shrink lets a long name truncate.
className="min-w-0 shrink"
>
<Avatar
src={agentImage}
alt=""
shape="circle"
className="shrink-0 overflow-hidden"
imgClassName="size-5 object-contain"
/>
{/* The list's muted colour would reach the name; the current crumb
is foreground, like BreadcrumbPage. */}
<span
className="text-foreground max-w-[24ch] truncate"
title={displayName}
>
{displayName}
</span>
<ChevronDown className="text-muted-foreground" aria-hidden />
</Button>
) : currentPage === 'overview' ? (
<BreadcrumbPage title={displayName} className="w-[16ch]">
{displayName}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={editPath} className="max-w-[40ch] truncate">
{displayName}
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{currentPage !== 'overview' && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{currentTabLabel}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbItem>
{currentPage !== 'overview' && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{currentTabLabel}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</BreadcrumbList>
</Breadcrumb>
{status}
</div>
<nav
aria-label={t('agents.pageHeader.subnavAriaLabel')}
className={cn(
'flex items-center gap-6',
// 1px baseline rule under the whole row; the active tab's 2px
// primary underline sits on top of it for the GitHub-style look.
!inline && 'border-border border-b',
)}
>
{tabs.map((tab) => {
const isActive = tab.id === currentPage;
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
if (isActive) {
{agentId && (
<nav
aria-label={t('agents.pageHeader.subnavAriaLabel')}
className={cn(
'flex items-center gap-6',
// 1px baseline rule under the whole row; the active tab's 2px
// primary underline sits on top of it for the GitHub-style look.
!inline && 'border-border border-b',
)}
>
{tabs.map((tab) => {
const isActive = tab.id === currentPage;
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
if (isActive) {
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
data-active
className="-mb-px"
>
<span aria-current="page">{tab.label}</span>
</Button>
);
}
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
data-active
className="-mb-px"
>
<span aria-current="page">{tab.label}</span>
<Link to={tab.href}>{tab.label}</Link>
</Button>
);
}
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
className="-mb-px"
>
<Link to={tab.href}>{tab.label}</Link>
</Button>
);
})}
</nav>
})}
</nav>
)}
</div>
);
}
@@ -0,0 +1,89 @@
import { Maximize, Minus, Plus, Redo2, Undo2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Panel, useReactFlow, useStore } from 'reactflow';
import { IconButton } from '@/components/ui/icon-button';
import { Separator } from '@/components/ui/separator';
interface CanvasControlsProps {
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
}
const ZOOM_DURATION_MS = 200;
/**
* The canvas's one control strip, bottom left: undo and redo, then zoom out,
* the zoom level, zoom in and fit view. Replaces React Flow's own
* `<Controls />`, whose buttons ignore the theme. Render inside `<ReactFlow>`.
*/
export default function CanvasControls({
onUndo,
onRedo,
canUndo,
canRedo,
}: CanvasControlsProps) {
const { t } = useTranslation();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const zoom = useStore((state) => state.transform[2]);
return (
<Panel
position="bottom-left"
className="bg-card border-border flex items-center gap-0.5 rounded-full border p-1"
>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onUndo}
disabled={!canUndo}
label={t('agents.workflow.undo')}
hint={t('agents.workflow.undoHint')}
icon={Undo2}
/>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onRedo}
disabled={!canRedo}
label={t('agents.workflow.redo')}
hint={t('agents.workflow.redoHint')}
icon={Redo2}
/>
<Separator orientation="vertical" className="mx-1 h-5" />
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() => zoomOut({ duration: ZOOM_DURATION_MS })}
label={t('agents.workflow.zoomOut')}
icon={Minus}
/>
<span className="text-foreground w-11 text-center text-xs tabular-nums">
{Math.round(zoom * 100)}%
</span>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() => zoomIn({ duration: ZOOM_DURATION_MS })}
label={t('agents.workflow.zoomIn')}
icon={Plus}
/>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() =>
fitView({ padding: 0.2, maxZoom: 0.8, duration: ZOOM_DURATION_MS })
}
label={t('agents.workflow.fitView')}
icon={Maximize}
/>
</Panel>
);
}
@@ -0,0 +1,83 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import NodePalette from './NodePalette';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('NodePalette', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (onAdd = vi.fn(), onDragStart = vi.fn()) => {
act(() => {
root.render(<NodePalette onAdd={onAdd} onDragStart={onDragStart} />);
});
return { onAdd, onDragStart };
};
const pill = (labelKey: string) =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes(labelKey),
) as HTMLButtonElement;
it('renders each node type as a draggable button', () => {
render();
const buttons = container.querySelectorAll('button');
expect(buttons).toHaveLength(6);
buttons.forEach((button) => {
expect(button.getAttribute('type')).toBe('button');
expect(button.getAttribute('draggable')).toBe('true');
});
});
it('adds a node of the pill type on click', () => {
const { onAdd } = render();
act(() => pill('agents.workflow.nodes.condition').click());
expect(onAdd).toHaveBeenCalledWith('condition');
act(() => pill('agents.workflow.builder.aiAgent').click());
expect(onAdd).toHaveBeenLastCalledWith('agent');
});
it('starts a drag with the pill type', () => {
const { onDragStart } = render();
const button = pill('agents.workflow.nodes.code');
act(() => {
button.dispatchEvent(new Event('dragstart', { bubbles: true }));
});
expect(onDragStart).toHaveBeenCalledWith(expect.anything(), 'code');
});
it('shows the shared tone on the icon square, with no hover swap', () => {
render();
const square = pill('agents.workflow.nodes.end').querySelector(
'span',
) as HTMLElement;
expect(square.className).toContain('bg-success/10');
// Radius by role: a tinted icon square at size-8 is rounded-md.
expect(square.className).toContain('rounded-md');
expect(container.innerHTML).not.toContain('group-hover:');
});
it('explains click-to-add under the groups', () => {
render();
expect(container.textContent).toContain(
'agents.workflow.builder.paletteHint',
);
});
});
@@ -0,0 +1,123 @@
import { type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { SectionHeader } from '@/components/ui/section-header';
import { cn, focusRing } from '@/lib/utils';
import { NODE_META, nodeToneClass, type WorkflowNodeType } from './nodeTones';
interface PaletteEntry {
type: WorkflowNodeType;
group: 'core' | 'logic';
hintKey?: string;
}
const PALETTE: PaletteEntry[] = [
{ type: 'agent', group: 'core' },
{ type: 'end', group: 'core' },
{ type: 'note', group: 'core' },
{
type: 'state',
group: 'logic',
hintKey: 'agents.workflow.builder.setStateHint',
},
{
type: 'condition',
group: 'logic',
hintKey: 'agents.workflow.builder.conditionHint',
},
{ type: 'code', group: 'logic', hintKey: 'agents.workflow.builder.codeHint' },
];
const GROUPS: { group: PaletteEntry['group']; titleKey: string }[] = [
{ group: 'core', titleKey: 'agents.workflow.builder.coreNodes' },
{ group: 'logic', titleKey: 'agents.workflow.builder.logicNodes' },
];
interface NodePaletteItemProps {
entry: PaletteEntry;
onAdd: (nodeType: WorkflowNodeType) => void;
onDragStart: (e: DragEvent, nodeType: string) => void;
}
/**
* A palette pill: drag it onto the canvas, or click (or press Enter) to add
* the node without dragging.
*
* Args:
* entry: The palette entry to render.
* onAdd: Adds a node of the entry's type beside the selection.
* onDragStart: Starts dragging a node of the entry's type onto the canvas.
*/
function NodePaletteItem({ entry, onAdd, onDragStart }: NodePaletteItemProps) {
const { t } = useTranslation();
const { icon: Icon, labelKey } = NODE_META[entry.type];
const label = (
<span className="text-foreground text-sm font-medium">{t(labelKey)}</span>
);
return (
<button
type="button"
draggable
onDragStart={(e) => onDragStart(e, entry.type)}
onClick={() => onAdd(entry.type)}
className={cn(
'border-border bg-card hover:bg-accent flex cursor-grab items-center gap-3 rounded-full border px-4 py-2.5 text-left transition-colors outline-none',
focusRing,
)}
>
<span
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md',
nodeToneClass(entry.type),
)}
>
<Icon className="size-4.5" aria-hidden="true" />
</span>
{entry.hintKey ? (
<span className="flex flex-col">
{label}
<span className="text-muted-foreground text-xs">
{t(entry.hintKey)}
</span>
</span>
) : (
label
)}
</button>
);
}
interface NodePaletteProps {
/** Adds a node of the given type without dragging (click or Enter). */
onAdd: (nodeType: WorkflowNodeType) => void;
/** Starts dragging a node of the given type onto the canvas. */
onDragStart: (e: DragEvent, nodeType: string) => void;
}
/** The builder's left rail of node types, grouped Core and Logic. */
export default function NodePalette({ onAdd, onDragStart }: NodePaletteProps) {
const { t } = useTranslation();
return (
<div className="border-border bg-background flex w-64 shrink-0 flex-col gap-6 overflow-y-auto border-r p-4">
{GROUPS.map(({ group, titleKey }) => (
<div key={group} className="flex flex-col gap-3">
<SectionHeader as="h3" size="sm" title={t(titleKey)} />
<div className="flex flex-col gap-2">
{PALETTE.filter((entry) => entry.group === group).map((entry) => (
<NodePaletteItem
key={entry.type}
entry={entry}
onAdd={onAdd}
onDragStart={onDragStart}
/>
))}
</div>
</div>
))}
<p className="text-muted-foreground text-xs">
{t('agents.workflow.builder.paletteHint')}
</p>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react';
/**
* Model display names by model id, provided by the builder so the canvas
* nodes (rendered by React Flow, out of reach of props) can name a model.
*/
export const WorkflowModelsContext = createContext<Record<string, string>>({});
/**
* A model's display name.
*
* Args:
* modelId: The model id stored on the node.
*
* Returns:
* The display name, the id itself when the model is unknown, or undefined
* when no id is set.
*/
export function useModelDisplayName(
modelId: string | undefined,
): string | undefined {
const names = useContext(WorkflowModelsContext);
if (!modelId) return undefined;
return names[modelId] || modelId;
}
@@ -66,7 +66,7 @@ export default function NodeDocumentsControl({
return (
<FormField label={label} hint={helpText} id={fieldId} float={false}>
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
<div className="border-border bg-card rounded-xl border p-1">
<div className="bg-muted rounded-full p-1">
<ToggleGroup
id={fieldId}
type="single"
@@ -121,7 +121,7 @@ export default function NodeDocumentsControl({
onClick={addRef}
className="shrink-0"
>
<Plus className="size-3.5" />
<Plus />
{t('agents.form.buttons.add')}
</Button>
</div>
@@ -337,6 +337,8 @@ interface PromptTextAreaProps {
placeholder?: string;
rows?: number;
label?: string;
/** Surface behind the field, for the floating label's notch. */
labelSurface?: 'card' | 'background' | 'muted';
}
export default function PromptTextArea({
@@ -348,6 +350,7 @@ export default function PromptTextArea({
placeholder,
rows = 4,
label,
labelSurface = 'card',
}: PromptTextAreaProps) {
const { t } = useTranslation();
const textareaId = useId();
@@ -442,7 +445,7 @@ export default function PromptTextArea({
// so typing keeps filtering it.
const field = (
<PopoverAnchor asChild>
<div className="border-border focus-within:border-ring focus-within:ring-ring/50 bg-card relative rounded-xl border transition-shadow focus-within:ring-3">
<div className="border-border focus-within:border-ring focus-within:ring-ring/50 relative rounded-xl border transition-shadow focus-within:ring-3">
<div
ref={overlayRef}
aria-hidden
@@ -525,7 +528,7 @@ export default function PromptTextArea({
}}
>
{label ? (
<FormField label={label} id={textareaId}>
<FormField label={label} id={textareaId} labelSurface={labelSurface}>
{field}
</FormField>
) : (
@@ -0,0 +1,113 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import WorkflowDetailsSheet from './WorkflowDetailsSheet';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const details = {
name: 'Helpdesk Triage',
description: 'Routes internal requests',
allowPromptOverride: false,
};
describe('WorkflowDetailsSheet', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (props: { errors?: string[]; saving?: boolean } = {}) => {
const handlers = { onOpenChange: vi.fn(), onSave: vi.fn() };
act(() => {
root.render(
<WorkflowDetailsSheet
open
details={details}
currentImage=""
saving={props.saving ?? false}
errors={props.errors ?? []}
{...handlers}
/>,
);
});
return handlers;
};
const button = (name: string) =>
Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent === name,
) as HTMLButtonElement;
const nameInput = () =>
document.querySelector('input[type="text"]') as HTMLInputElement;
const type = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
act(() => {
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
};
it('opens with the workflow details filled in', () => {
render();
expect(document.body.textContent).toContain(
'agents.workflow.builder.detailsTitle',
);
expect(nameInput().value).toBe('Helpdesk Triage');
expect(document.querySelector('textarea')?.value).toBe(
'Routes internal requests',
);
expect(document.querySelector('[role="switch"]')).not.toBeNull();
});
it('keeps Save disabled until something changes, then saves the edits', () => {
const { onSave } = render();
const save = button('agents.form.buttons.save');
expect(save.disabled).toBe(true);
type(nameInput(), 'Helpdesk Router');
expect(save.disabled).toBe(false);
act(() => save.click());
expect(onSave).toHaveBeenCalledWith({
name: 'Helpdesk Router',
description: 'Routes internal requests',
allowPromptOverride: false,
imageFile: null,
});
});
it('closes on Cancel without saving', () => {
const { onOpenChange, onSave } = render();
type(nameInput(), 'Something else');
act(() => button('agents.form.buttons.cancel').click());
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onSave).not.toHaveBeenCalled();
});
it('lists save errors in an alert inside the sheet', () => {
render({ errors: ['Workflow must have an end node'] });
const alert = document.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('agents.workflow.builder.unableSave');
expect(alert?.textContent).toContain('Workflow must have an end node');
});
});
@@ -0,0 +1,226 @@
import { CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { SectionHeader } from '@/components/ui/section-header';
import { Separator } from '@/components/ui/separator';
import { SettingRow } from '@/components/ui/setting-row';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { FileUpload } from '../../../components/FileUpload';
export interface WorkflowDetailsValues {
name: string;
description: string;
allowPromptOverride: boolean;
}
export interface WorkflowDetailsSave extends WorkflowDetailsValues {
/** A newly picked avatar, or null to keep the current one. */
imageFile: File | null;
}
interface WorkflowDetailsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The builder's current values; the sheet edits a copy of them. */
details: WorkflowDetailsValues;
/** The saved avatar's URL, shown in the tile until a new one is picked. */
currentImage: string;
saving: boolean;
/** Errors from the last save started here, shown at the top of the body. */
errors: string[];
onSave: (values: WorkflowDetailsSave) => void;
}
/**
* The workflow's name, description, avatar and prompt-override setting, in a
* right drawer laid out like the classic agent's Basics on a phone.
*
* The sheet edits a copy: Cancel, the X, Escape and the scrim drop the edits,
* and Save hands them to the builder, which saves the workflow.
*
* Args:
* open: Whether the sheet is open.
* onOpenChange: Opens or closes the sheet.
* details: The values to start from each time it opens.
* currentImage: The saved avatar's URL.
* saving: Whether a save started here is running.
* errors: Errors from that save.
* onSave: Receives the edited values.
*/
export default function WorkflowDetailsSheet({
open,
onOpenChange,
details,
currentImage,
saving,
errors,
onSave,
}: WorkflowDetailsSheetProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<WorkflowDetailsValues>(details);
const [imageFile, setImageFile] = useState<File | null>(null);
// Start from the builder's values every time the sheet opens.
useEffect(() => {
if (!open) return;
setDraft(details);
setImageFile(null);
// Only on open: later changes to `details` come from this sheet's own save.
}, [open]);
const dirty =
imageFile !== null ||
draft.name !== details.name ||
draft.description !== details.description ||
draft.allowPromptOverride !== details.allowPromptOverride;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
size="default"
className="p-0"
closeLabel={t('agents.close')}
>
<div className="flex min-h-0 flex-1 flex-col">
{/* pr-12 keeps the header clear of the close X at top-2 right-2. */}
<div className="flex flex-col gap-1 px-6 pt-6 pr-12 pb-4">
<SheetTitle>{t('agents.workflow.builder.detailsTitle')}</SheetTitle>
<SheetDescription>
{t('agents.workflow.builder.detailsDescription')}
</SheetDescription>
</div>
<Separator />
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-6">
{errors.length > 0 && (
<Alert variant="destructive">
<CircleX />
<AlertTitle>
{t('agents.workflow.builder.unableSave')}
</AlertTitle>
<AlertDescription>
<ul className="flex list-inside list-disc flex-col gap-1 wrap-break-word">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<Card variant="subtle" padding="lg" className="gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.basics')}
/>
{/* The phone layout of classic Basics: the avatar beside Name,
Description across the row. */}
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-5">
<FileUpload
showPreview
size="tile"
tileSize="fixed"
currentImage={currentImage || undefined}
onUpload={(files) => setImageFile(files[0] ?? null)}
onRemove={() => setImageFile(null)}
uploadText={t('agents.form.labels.avatar')}
/>
<FormField
labelSurface="background"
label={t('agents.form.labels.name')}
>
<Input
shape="pill"
type="text"
value={draft.name}
placeholder={t(
'agents.workflow.builder.workflowNamePlaceholder',
)}
onChange={(e) =>
setDraft({ ...draft, name: e.target.value })
}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.form.labels.description')}
className="col-span-2"
>
<Textarea
size="lg"
className="h-32"
placeholder={t(
'agents.workflow.builder.workflowDescriptionPlaceholder',
)}
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.target.value })
}
/>
</FormField>
</div>
</Card>
<Card variant="subtle" padding="lg" className="gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.advanced')}
/>
<SettingRow
label={t('agents.form.advanced.systemPromptOverride')}
description={t(
'agents.form.advanced.systemPromptOverrideDescription',
)}
htmlFor="workflow-system-prompt-override"
>
<Switch
id="workflow-system-prompt-override"
checked={draft.allowPromptOverride}
onCheckedChange={(checked) =>
setDraft({ ...draft, allowPromptOverride: checked })
}
/>
</SettingRow>
</Card>
</div>
<Separator />
<div className="flex justify-end gap-3 px-6 py-4">
<Button
type="button"
variant="ghost"
size="lg"
shape="pill"
onClick={() => onOpenChange(false)}
>
{t('agents.form.buttons.cancel')}
</Button>
<Button
type="button"
size="lg"
shape="pill"
disabled={!dirty}
loading={saving}
onClick={() => onSave({ ...draft, imageFile })}
>
{t('agents.form.buttons.save')}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { NODE_TONES, nodeToneClass, TONE_CLASSES } from './nodeTones';
describe('nodeTones', () => {
it('maps every node type to one tone', () => {
expect(NODE_TONES).toEqual({
start: 'success',
agent: 'primary',
end: 'success',
note: 'warning',
state: 'info',
condition: 'warning',
code: 'info',
});
});
it('never paints End red', () => {
expect(nodeToneClass('end')).not.toMatch(/destructive/);
expect(nodeToneClass('end')).toBe('bg-success/10 text-success');
});
it('uses the brand soft fill for primary, not bg-primary/10', () => {
expect(TONE_CLASSES.primary).toBe('bg-secondary text-secondary-foreground');
expect(nodeToneClass('agent')).toBe(TONE_CLASSES.primary);
});
it('gives status tones their /10 fill and text', () => {
expect(TONE_CLASSES.info).toBe('bg-info/10 text-info');
expect(TONE_CLASSES.warning).toBe('bg-warning/10 text-warning');
});
it('falls back to a neutral tint for an unknown type', () => {
expect(nodeToneClass('mystery')).toBe(
'bg-muted-foreground/15 text-muted-foreground',
);
});
});
+71
View File
@@ -0,0 +1,71 @@
import {
Bot,
CodeXml,
Database,
Flag,
GitBranch,
Play,
StickyNote,
type LucideIcon,
} from 'lucide-react';
/** The node types the builder draws. */
export type WorkflowNodeType =
'start' | 'agent' | 'end' | 'note' | 'state' | 'condition' | 'code';
/** A node's colour role: the brand tint or one status tint. */
export type NodeTone = 'primary' | 'success' | 'warning' | 'info';
/**
* One colour per node type, read by the palette, every canvas node and the
* settings panel's header. Start and End share `success` as the two ends of
* the flow; `destructive` is kept for errors, so End is never red.
*/
export const NODE_TONES: Record<WorkflowNodeType, NodeTone> = {
start: 'success',
agent: 'primary',
end: 'success',
note: 'warning',
state: 'info',
condition: 'warning',
code: 'info',
};
// Whole class strings per tone so Tailwind sees them. Brand is the soft
// `secondary` fill (never bg-primary/10); status tones are the /10 fill.
export const TONE_CLASSES: Record<NodeTone, string> = {
primary: 'bg-secondary text-secondary-foreground',
success: 'bg-success/10 text-success',
warning: 'bg-warning/10 text-warning',
info: 'bg-info/10 text-info',
};
const NEUTRAL_TONE_CLASS = 'bg-muted-foreground/15 text-muted-foreground';
/**
* The icon-square classes for a node type.
*
* Args:
* type: The node's type.
*
* Returns:
* The tone's fill and text classes, or a neutral tint for an unknown type.
*/
export function nodeToneClass(type: string | undefined): string {
const tone = NODE_TONES[type as WorkflowNodeType];
return tone ? TONE_CLASSES[tone] : NEUTRAL_TONE_CLASS;
}
/** Each node type's icon and its name's locale key. */
export const NODE_META: Record<
WorkflowNodeType,
{ icon: LucideIcon; labelKey: string }
> = {
start: { icon: Play, labelKey: 'agents.workflow.nodes.start' },
agent: { icon: Bot, labelKey: 'agents.workflow.builder.aiAgent' },
end: { icon: Flag, labelKey: 'agents.workflow.nodes.end' },
note: { icon: StickyNote, labelKey: 'agents.workflow.nodes.note' },
state: { icon: Database, labelKey: 'agents.workflow.nodes.setState' },
condition: { icon: GitBranch, labelKey: 'agents.workflow.nodes.condition' },
code: { icon: CodeXml, labelKey: 'agents.workflow.nodes.code' },
};
+14 -34
View File
@@ -3,11 +3,14 @@ import { Handle, Position } from 'reactflow';
import { cn } from '@/lib/utils';
import { nodeToneClass, type WorkflowNodeType } from '../nodeTones';
interface BaseNodeProps {
title: string;
children?: ReactNode;
selected?: boolean;
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition' | 'code';
/** The node type; picks the icon square's tone from the shared map. */
type: WorkflowNodeType;
icon?: ReactNode;
handles?: {
source?: boolean;
@@ -15,47 +18,25 @@ interface BaseNodeProps {
};
}
/**
* The pill every canvas node but Note and If / Else is drawn as: a tinted
* icon, the title, a muted meta block and the handles. Selected is the focus
* ring look (`border-primary ring-3 ring-ring/50`), with no scale, so the
* node stays put under the pointer.
*/
export const BaseNode: React.FC<BaseNodeProps> = ({
title,
children,
selected,
type = 'default',
type,
icon,
handles = { source: true, target: true },
}) => {
let bgColor = 'bg-card';
let borderColor = 'border-border';
let iconBg = 'bg-muted';
let iconColor = 'text-muted-foreground';
if (selected) {
borderColor = 'border-primary ring-2 ring-primary';
}
if (type === 'start') {
iconBg = 'bg-success/10';
iconColor = 'text-success';
} else if (type === 'end') {
iconBg = 'bg-destructive/10';
iconColor = 'text-destructive';
} else if (type === 'state') {
iconBg = 'bg-muted';
iconColor = 'text-muted-foreground';
} else if (type === 'condition') {
iconBg = 'bg-warning/10';
iconColor = 'text-warning';
} else if (type === 'code') {
iconBg = 'bg-info/10';
iconColor = 'text-info';
}
return (
<div
className={cn(
'rounded-full border shadow-md transition hover:shadow-lg',
bgColor,
borderColor,
selected && 'scale-105',
'bg-card rounded-full border shadow-md transition hover:shadow-lg',
selected ? 'border-primary ring-ring/50 ring-3' : 'border-border',
'max-w-[250px] min-w-[180px]',
)}
>
@@ -72,8 +53,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
iconBg,
iconColor,
nodeToneClass(type),
)}
>
{icon}
@@ -5,9 +5,7 @@ import { NodeProps } from 'reactflow';
import { CodeNodeConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
import OutputVariableLine from './OutputVariableLine';
type CodeNodeData = {
title?: string;
@@ -46,18 +44,7 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
</div>
)}
{config.output_variable && (
<div
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
<OutputVariableLine variable={config.output_variable} />
)}
</div>
</BaseNode>
@@ -6,6 +6,7 @@ import { Handle, NodeProps, Position } from 'reactflow';
import { cn } from '@/lib/utils';
import { ConditionCase } from '../../types/workflow';
import { nodeToneClass } from '../nodeTones';
type ConditionNodeData = {
label?: string;
@@ -44,7 +45,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
className={cn(
'bg-card relative rounded-2xl border shadow-md transition',
selected
? 'border-primary ring-primary scale-105 ring-2'
? 'border-primary ring-ring/50 ring-3'
: 'border-border hover:shadow-lg',
)}
style={{ minWidth: 180, maxWidth: 220, height }}
@@ -57,7 +58,12 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
/>
<div className="flex items-center gap-3 px-3 py-2">
<div className="bg-warning/10 text-warning flex size-9 shrink-0 items-center justify-center rounded-full">
<div
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full',
nodeToneClass('condition'),
)}
>
<GitBranch className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-2">
@@ -69,8 +75,8 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
</div>
<div className="text-muted-foreground text-xs">
{data.config?.mode === 'advanced'
? t('agents.workflow.nodes.modeAdvanced')
: t('agents.workflow.nodes.modeSimple')}
? t('agents.workflow.builder.modeAdvanced')
: t('agents.workflow.builder.modeSimple')}
</div>
</div>
</div>
@@ -0,0 +1,25 @@
import { ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
/**
* A node's output variable on the canvas: an arrow, then the name in mono,
* because it is what the next node reads.
*
* Args:
* variable: The output variable's name.
*/
export default function OutputVariableLine({ variable }: { variable: string }) {
const { t } = useTranslation();
return (
<div
className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs"
title={t('agents.workflow.nodes.output', { ...NO_ESCAPE, variable })}
>
<ArrowRight className="size-3 shrink-0" aria-hidden="true" />
<span className="truncate font-mono">{variable}</span>
</div>
);
}
+20 -31
View File
@@ -4,14 +4,14 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { nodeToneClass } from '../nodeTones';
import { useModelDisplayName } from '../WorkflowModelsContext';
import { BaseNode } from './BaseNode';
import OutputVariableLine from './OutputVariableLine';
import CodeNode from './CodeNode';
import ConditionNode from './ConditionNode';
import SetStateNode from './SetStateNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
export const StartNode = memo(function StartNode({
selected,
}: {
@@ -73,6 +73,12 @@ export const AgentNode = memo(function AgentNode({
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.agent');
const config = data.config || {};
const modelName = useModelDisplayName(config.model_id);
const agentType =
config.agent_type === 'research' || config.agent_type === 'classic'
? t(`agents.form.agentTypes.${config.agent_type}`)
: config.agent_type;
const meta = [agentType, modelName].filter(Boolean).join(' · ');
return (
<BaseNode
title={title}
@@ -81,35 +87,13 @@ export const AgentNode = memo(function AgentNode({
icon={<Bot className="size-4" />}
>
<div className="flex flex-col gap-1">
{config.agent_type && (
<div
className="text-muted-foreground truncate text-xs"
title={config.agent_type}
>
{config.agent_type}
</div>
)}
{config.model_id && (
<div
className="text-primary truncate text-xs"
title={config.model_id}
>
{config.model_id}
{meta && (
<div className="text-muted-foreground truncate text-xs" title={meta}>
{meta}
</div>
)}
{config.output_variable && (
<div
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
<OutputVariableLine variable={config.output_variable} />
)}
</div>
</BaseNode>
@@ -138,12 +122,17 @@ export const NoteNode = memo(function NoteNode({
// over it as a flat gradient, so the canvas grid doesn't show through.
'bg-card from-warning/10 to-warning/10 max-w-[250px] rounded-3xl border bg-linear-to-b px-5 py-3 shadow-md transition',
selected
? 'border-warning ring-warning scale-105 ring-2'
? 'border-warning ring-warning/50 ring-3'
: 'border-warning/50 hover:shadow-lg',
)}
>
<div className="flex items-start gap-3">
<div className="bg-warning/15 text-warning flex size-10 shrink-0 items-center justify-center rounded-full">
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
nodeToneClass('note'),
)}
>
<StickyNote className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
@@ -0,0 +1,138 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import AgentPanel from './AgentPanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const makeNode = (config: Record<string, unknown> = {}): Node => ({
id: 'agent_1',
type: 'agent',
position: { x: 0, y: 0 },
data: {
title: 'Classify',
config: {
agent_type: 'classic',
system_prompt: '',
prompt_template: '',
stream_to_user: true,
sources: [],
tools: [],
...config,
},
},
});
describe('AgentPanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (node: Node, jsonSchemaText = '') => {
const onUpdate = vi.fn();
act(() => {
root.render(
<AgentPanel
node={node}
onUpdate={onUpdate}
nodes={[node]}
edges={[]}
availableModels={[]}
availableTools={[]}
sourceOptions={[]}
documentOptions={[]}
jsonSchemaText={jsonSchemaText}
jsonSchemaError={null}
modelSupportsStructuredOutput
onJsonSchemaChange={vi.fn()}
/>,
);
});
return { onUpdate };
};
const advancedToggle = () =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.advancedSettings'),
) as HTMLButtonElement;
it('groups the fields under Model, Prompt, Knowledge and Output', () => {
render(makeNode());
const headings = Array.from(container.querySelectorAll('h3')).map(
(h) => h.textContent,
);
expect(headings).toEqual([
'agents.form.sections.model',
'agents.form.sections.prompt',
'agents.workflow.builder.knowledge',
'agents.workflow.builder.output',
]);
});
it('stacks Agent type and Model at full width in the narrow panel', () => {
render(makeNode());
const modelSection = container.querySelector('section')!;
expect(modelSection.querySelector('.grid')).toBeNull();
const triggers = modelSection.querySelectorAll('[role="combobox"]');
expect(triggers).toHaveLength(2);
triggers.forEach((trigger) =>
expect(trigger.className).toContain('w-full'),
);
});
it('streams through a Switch with a description, not a Checkbox', () => {
const { onUpdate } = render(makeNode());
expect(container.querySelector('[role="checkbox"]')).toBeNull();
const toggle = container.querySelector(
'[role="switch"]',
) as HTMLButtonElement;
expect(toggle.getAttribute('aria-checked')).toBe('true');
expect(container.textContent).toContain(
'agents.workflow.builder.streamToUserDescription',
);
act(() => toggle.click());
expect(onUpdate).toHaveBeenCalledWith({
config: expect.objectContaining({ stream_to_user: false }),
});
});
it('keeps advanced settings closed when none is set', () => {
render(makeNode());
const toggle = advancedToggle();
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(container.textContent).not.toContain(
'agents.workflow.builder.filePassing',
);
act(() => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(container.textContent).toContain(
'agents.workflow.builder.filePassing',
);
});
it('opens advanced settings when one of them is set', () => {
render(makeNode({ file_passing: 'native' }));
expect(advancedToggle().getAttribute('aria-expanded')).toBe('true');
});
it('opens advanced settings when a schema is present', () => {
render(makeNode({ json_schema: { type: 'object' } }), '{"type":"object"}');
expect(advancedToggle().getAttribute('aria-expanded')).toBe('true');
});
});
@@ -0,0 +1,403 @@
import { ChevronRight } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type Edge, type Node } from 'reactflow';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import { SectionHeader } from '@/components/ui/section-header';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SettingRow, SettingRows } from '@/components/ui/setting-row';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { getToolDisplayName } from '../../../utils/toolUtils';
import NodeDocumentsControl from '../components/NodeDocumentsControl';
import PromptTextArea from '../components/PromptTextArea';
import {
DEFAULT_FILE_PASSING,
FILE_PASSING_OPTIONS,
FilePassing,
normalizeFilePassing,
} from '../documentConfig';
import { NO_ESCAPE, schemaErrorText, type UserTool } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
import type { Model } from '../../../models/types';
interface AgentPanelProps extends NodePanelBodyProps {
nodes: Node[];
edges: Edge[];
availableModels: Model[];
availableTools: UserTool[];
sourceOptions: { value: string; label: string }[];
/** Upstream file variables the agent can read. */
documentOptions: { value: string; label: string }[];
/** The structured-output schema as typed. */
jsonSchemaText: string;
/** The schema's validation fragment, or null. */
jsonSchemaError: string | null;
/** False when the picked model can't return structured output. */
modelSupportsStructuredOutput: boolean;
onJsonSchemaChange: (text: string) => void;
}
const SCHEMA_PLACEHOLDER = `{
"type": "object",
"properties": {
"summary": { "type": "string" }
},
"required": ["summary"]
}`;
/**
* Whether any advanced setting (documents, file passing, structured output)
* differs from its default, so the disclosure opens on its own.
*
* Args:
* config: The agent node's config.
* jsonSchemaText: The schema as typed.
* jsonSchemaError: The schema's validation fragment, or null.
*
* Returns:
* True when the advanced settings should start open.
*/
export function hasAdvancedAgentSettings(
config: Record<string, unknown> | undefined,
jsonSchemaText: string,
jsonSchemaError: string | null,
): boolean {
const inputDocuments = config?.input_documents;
return (
(Array.isArray(inputDocuments) && inputDocuments.length > 0) ||
normalizeFilePassing(config?.file_passing) !== DEFAULT_FILE_PASSING ||
(config?.json_schema !== undefined && config?.json_schema !== null) ||
jsonSchemaText.trim() !== '' ||
Boolean(jsonSchemaError)
);
}
/**
* Settings for an AI Agent node, grouped Model, Prompt, Knowledge and Output
* like classic Overview, with documents, file passing and structured output
* under an Advanced settings disclosure.
*/
export default function AgentPanel({
node,
onUpdate,
nodes,
edges,
availableModels,
availableTools,
sourceOptions,
documentOptions,
jsonSchemaText,
jsonSchemaError,
modelSupportsStructuredOutput,
onJsonSchemaChange,
}: AgentPanelProps) {
const { t } = useTranslation();
const streamId = useId();
const config = node.data.config || {};
const [advancedOpen, setAdvancedOpen] = useState(() =>
hasAdvancedAgentSettings(config, jsonSchemaText, jsonSchemaError),
);
const updateConfig = (patch: Record<string, unknown>) =>
onUpdate({ config: { ...(node.data.config || {}), ...patch } });
const builtinModels = availableModels.filter((m) => m.source !== 'user');
const userModels = availableModels.filter((m) => m.source === 'user');
const schemaInvalid = jsonSchemaText.trim() !== '' && jsonSchemaError;
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.model')}
/>
<div className="flex flex-col gap-5">
<FormField
label={t('agents.workflow.builder.agentType')}
labelSurface="background"
>
<Select
value={config.agent_type || 'classic'}
onValueChange={(value) => updateConfig({ agent_type: value })}
>
<SelectTrigger size="field" className="w-full">
<SelectValue
placeholder={t(
'agents.workflow.builder.agentTypePlaceholder',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="classic">
{t('agents.form.agentTypes.classic')}
</SelectItem>
<SelectItem value="research">
{t('agents.form.agentTypes.research')}
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.model')}
labelSurface="background"
>
<Select
value={config.model_id || ''}
onValueChange={(value) =>
updateConfig({
model_id: value,
llm_name:
availableModels.find((m) => m.id === value)?.provider || '',
})
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue
placeholder={t('agents.workflow.builder.modelPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{builtinModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t('settings.customModels.modelsGroup.builtin')}
</SelectLabel>
{builtinModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.display_name} · {model.provider}
</SelectItem>
))}
</SelectGroup>
)}
{userModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t('settings.customModels.modelsGroup.user')}
</SelectLabel>
{userModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.display_name} · {model.provider}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</FormField>
</div>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.prompt')}
/>
<FormField
label={t('agents.workflow.builder.systemPrompt')}
labelSurface="background"
>
<Textarea
value={config.system_prompt ?? ''}
onChange={(e) => updateConfig({ system_prompt: e.target.value })}
rows={3}
placeholder={t('agents.workflow.builder.systemPromptPlaceholder')}
/>
</FormField>
<PromptTextArea
label={t('agents.workflow.builder.promptTemplate')}
labelSurface="background"
value={config.prompt_template || ''}
onChange={(val) => updateConfig({ prompt_template: val })}
nodes={nodes}
edges={edges}
selectedNodeId={node.id}
placeholder={t('agents.workflow.builder.promptTemplatePlaceholder', {
...NO_ESCAPE,
example: '{{ agent.variable }}',
})}
/>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.workflow.builder.knowledge')}
/>
<FormField
label={t('agents.workflow.builder.sources')}
labelSurface="background"
>
<MultiSelect
options={sourceOptions}
selected={config.sources || []}
onChange={(newSources) => updateConfig({ sources: newSources })}
placeholder={t('agents.form.placeholders.selectSources')}
searchPlaceholder={t('agents.form.sourcePopup.searchPlaceholder')}
emptyText={t('agents.form.sourcePopup.noOptionsMessage')}
/>
</FormField>
<FormField
label={t('agents.form.sections.tools')}
labelSurface="background"
>
<MultiSelect
options={availableTools.map((tool) => ({
value: tool.id,
label: getToolDisplayName(tool),
}))}
selected={config.tools || []}
onChange={(newTools) => updateConfig({ tools: newTools })}
placeholder={t('agents.form.placeholders.selectTools')}
searchPlaceholder={t('agents.form.toolsPopup.searchPlaceholder')}
emptyText={t('agents.form.toolsPopup.noOptionsMessage')}
/>
</FormField>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.workflow.builder.output')}
/>
<FormField
label={t('agents.workflow.builder.outputVariable')}
labelSurface="background"
>
<Input
type="text"
value={config.output_variable || ''}
onChange={(e) => updateConfig({ output_variable: e.target.value })}
placeholder={t('agents.workflow.builder.outputVariablePlaceholder')}
/>
</FormField>
<SettingRows>
<SettingRow
label={t('agents.workflow.builder.streamToUser')}
description={t('agents.workflow.builder.streamToUserDescription')}
htmlFor={streamId}
>
<Switch
id={streamId}
checked={config.stream_to_user ?? true}
onCheckedChange={(checked) =>
updateConfig({ stream_to_user: checked })
}
/>
</SettingRow>
</SettingRows>
</section>
<div className="flex flex-col gap-5">
<Button
type="button"
variant="link"
size="sm"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((open) => !open)}
className="-ml-3 w-fit justify-start"
>
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform duration-200',
advancedOpen && 'rotate-90',
)}
/>
{t('agents.workflow.builder.advancedSettings')}
</Button>
{advancedOpen && (
<div className="flex flex-col gap-5">
<NodeDocumentsControl
key={node.id}
value={config.input_documents ?? []}
onChange={(nextInputDocuments) =>
updateConfig({ input_documents: nextInputDocuments })
}
options={documentOptions}
label={t('agents.workflow.builder.documents')}
helpText={t('agents.workflow.builder.documentsHint')}
/>
<FormField
label={t('agents.workflow.builder.filePassing')}
hint={t('agents.workflow.builder.filePassingHint')}
labelSurface="background"
>
<Select
value={normalizeFilePassing(config.file_passing)}
onValueChange={(value) =>
updateConfig({ file_passing: value as FilePassing })
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILE_PASSING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(`agents.workflow.filePassing.${option.value}`, {
defaultValue: option.label,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.structuredOutput')}
labelSurface="background"
hint={
[
!modelSupportsStructuredOutput
? t('agents.workflow.builder.modelNoStructuredOutput')
: null,
jsonSchemaText.trim() !== '' && !jsonSchemaError
? t('agents.workflow.builder.validSchema')
: null,
]
.filter(Boolean)
.join(' ') || undefined
}
error={
schemaInvalid
? t('agents.workflow.builder.invalidSchema', {
...NO_ESCAPE,
error: schemaErrorText(t, jsonSchemaError),
})
: undefined
}
>
<Textarea
value={jsonSchemaText}
onChange={(e) => onJsonSchemaChange(e.target.value)}
className="font-mono"
rows={4}
placeholder={SCHEMA_PLACEHOLDER}
/>
</FormField>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,152 @@
import { useTranslation } from 'react-i18next';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import NodeDocumentsControl from '../components/NodeDocumentsControl';
import { NO_ESCAPE, schemaErrorText } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
interface CodePanelProps extends NodePanelBodyProps {
/** Upstream file variables the code can take as inputs. */
documentOptions: { value: string; label: string }[];
/** The structured-output schema as typed. */
jsonSchemaText: string;
/** The schema's validation fragment, or null. */
jsonSchemaError: string | null;
onJsonSchemaChange: (text: string) => void;
}
/** Settings for a Code node: code, inputs, output and schema. */
export default function CodePanel({
node: selectedNode,
onUpdate: handleUpdateNodeData,
documentOptions: selectedCodeDocumentOptions,
jsonSchemaText: selectedCodeJsonSchemaText,
jsonSchemaError: selectedCodeJsonSchemaError,
onJsonSchemaChange: handleCodeJsonSchemaChange,
}: CodePanelProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.codeIntro')}
</p>
<FormField
labelSurface="background"
label={t('agents.workflow.nodes.code')}
>
<Textarea
value={selectedNode.data.config?.code ?? ''}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
code: e.target.value,
},
})
}
className="font-mono"
rows={10}
spellCheck={false}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'print("hello world")',
})}
/>
</FormField>
<NodeDocumentsControl
key={selectedNode.id}
value={selectedNode.data.config?.inputs ?? []}
onChange={(nextInputs) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
inputs: nextInputs,
},
})
}
options={selectedCodeDocumentOptions}
label={t('agents.workflow.builder.inputFiles')}
helpText={t('agents.workflow.builder.inputFilesHint')}
/>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.outputVariable')}
>
<Input
type="text"
value={selectedNode.data.config?.output_variable || ''}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
output_variable: e.target.value,
},
})
}
placeholder={t('agents.workflow.builder.outputVariablePlaceholder')}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.timeout')}
>
<Input
type="number"
min={1}
value={selectedNode.data.config?.timeout ?? ''}
onChange={(e) => {
const raw = e.target.value;
const parsed =
raw.trim() === '' ? undefined : Number.parseInt(raw, 10);
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
timeout:
parsed !== undefined && Number.isFinite(parsed)
? parsed
: undefined,
},
});
}}
placeholder={t('agents.workflow.builder.optional')}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.structuredOutput')}
hint={
selectedCodeJsonSchemaText.trim() !== '' &&
!selectedCodeJsonSchemaError
? t('agents.workflow.builder.validSchema')
: undefined
}
error={
selectedCodeJsonSchemaText.trim() !== '' &&
selectedCodeJsonSchemaError
? t('agents.workflow.builder.invalidSchema', {
...NO_ESCAPE,
error: schemaErrorText(t, selectedCodeJsonSchemaError),
})
: undefined
}
>
<Textarea
value={selectedCodeJsonSchemaText}
onChange={(e) => handleCodeJsonSchemaChange(e.target.value)}
className="font-mono"
rows={6}
placeholder={`{
"type": "object",
"properties": {
"result": { "type": "string" }
},
"required": ["result"]
}`}
/>
</FormField>
</div>
);
}
@@ -0,0 +1,107 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
import ConditionPanel from './ConditionPanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const node: Node = {
id: 'condition_1',
type: 'condition',
position: { x: 0, y: 0 },
data: {
title: 'Route',
config: {
mode: 'simple',
cases: [
{
name: 'People Ops',
expression: 'category.contains("people")',
sourceHandle: 'case_0',
},
],
},
},
};
describe('ConditionPanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = () => {
const onUpdate = vi.fn();
act(() => {
root.render(
<ConditionPanel
node={node}
onUpdate={onUpdate}
onRemoveBranch={vi.fn()}
/>,
);
});
return { onUpdate };
};
it('picks the mode with a ToggleGroup in the muted track', () => {
const { onUpdate } = render();
const group = container.querySelector('[role="radiogroup"]')!;
expect(group.parentElement!.className).toContain('bg-muted');
const advanced = Array.from(group.querySelectorAll('button')).find(
(b) => b.textContent === 'agents.workflow.builder.modeAdvanced',
)!;
act(() => advanced.click());
expect(onUpdate).toHaveBeenCalledWith({
config: expect.objectContaining({ mode: 'advanced' }),
});
});
it('labels the branch name, variable, operator and value fields', () => {
render();
const labels = Array.from(
container.querySelectorAll('[data-slot="form-field-label"]'),
).map((l) => l.textContent);
expect(labels).toEqual([
'agents.workflow.builder.branchName',
'agents.workflow.builder.variable',
'agents.workflow.builder.operator',
'agents.workflow.builder.value',
]);
});
it('wires the operator label to its select, with no aria-label', () => {
render();
const trigger = container.querySelector('[role="combobox"]')!;
expect(trigger.hasAttribute('aria-label')).toBe(false);
const label = container.querySelector(`label[for="${trigger.id}"]`);
expect(label?.textContent).toBe('agents.workflow.builder.operator');
});
it('adds a condition with an outline pill button', () => {
const { onUpdate } = render();
const add = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.addCondition'),
)!;
expect(add.getAttribute('data-variant')).toBe('outline');
act(() => add.click());
const cases = onUpdate.mock.calls[0][0].config.cases;
expect(cases).toHaveLength(2);
expect(cases[1].sourceHandle).toBe('case_1');
});
});
@@ -0,0 +1,291 @@
import { Plus, Trash2 } from 'lucide-react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SectionHeader } from '@/components/ui/section-header';
import { Textarea } from '@/components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { ConditionCase } from '../../types/workflow';
import { buildSimpleCel, parseSimpleCel } from '../simpleCel';
import {
getNextConditionHandle,
normalizeConditionCases,
} from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
interface ConditionPanelProps extends NodePanelBodyProps {
/** Drop the edges leaving the node from a removed case's handle. */
onRemoveBranch: (sourceHandle: string) => void;
}
type ConditionMode = 'simple' | 'advanced';
/**
* Settings for an If / Else node: Simple or Advanced mode, then one row box
* per case (If, Else if, …) and Add condition.
*/
export default function ConditionPanel({
node,
onUpdate,
onRemoveBranch,
}: ConditionPanelProps) {
const { t } = useTranslation();
const config = node.data.config || {};
const mode: ConditionMode =
config.mode === 'advanced' ? 'advanced' : 'simple';
const cases: ConditionCase[] = config.cases || [];
const updateConfig = (patch: Record<string, unknown>) =>
onUpdate({ config: { ...(node.data.config || {}), ...patch } });
const updateCase = (idx: number, patch: Partial<ConditionCase>) => {
const next = [...cases];
next[idx] = { ...next[idx], ...patch };
updateConfig({ cases: next });
};
const removeCase = (idx: number) => {
const next = normalizeConditionCases([...cases]);
const removedHandle = next[idx]?.sourceHandle;
next.splice(idx, 1);
updateConfig({ cases: next });
if (removedHandle) onRemoveBranch(removedHandle);
};
const addCase = () => {
const next = normalizeConditionCases([...cases]);
next.push({
name: '',
expression: '',
sourceHandle: getNextConditionHandle(next),
});
updateConfig({ cases: next });
};
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.conditionIntro')}
</p>
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
<div className="bg-muted rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
value={mode}
onValueChange={(next) => next && updateConfig({ mode: next })}
aria-label={t('agents.workflow.builder.conditionMode')}
className="flex-nowrap"
>
<ToggleGroupItem value="simple" className="flex-1">
{t('agents.workflow.builder.modeSimple')}
</ToggleGroupItem>
<ToggleGroupItem value="advanced" className="flex-1">
{t('agents.workflow.builder.modeAdvanced')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{cases.map((c, idx) => {
const parsed = parseSimpleCel(c.expression);
return (
<Card
key={c.sourceHandle}
variant="subtle"
padding="sm"
className="gap-4"
>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="bg-warning size-2 shrink-0 rounded-full"
/>
<SectionHeader
as="h4"
size="xs"
className="flex-1"
title={
idx === 0
? t('agents.workflow.nodes.if')
: t('agents.workflow.nodes.elseIf')
}
/>
{cases.length > 1 && (
<IconButton
variant="ghost-destructive"
size="icon-xs"
label={t('agents.workflow.removeCondition', {
index: idx + 1,
})}
icon={Trash2}
onClick={() => removeCase(idx)}
/>
)}
</div>
<FormField
label={t('agents.workflow.builder.branchName')}
labelSurface="background"
>
<Input
type="text"
value={c.name || ''}
onChange={(e) => updateCase(idx, { name: e.target.value })}
placeholder={t('agents.workflow.builder.caseNamePlaceholder')}
/>
</FormField>
{mode === 'simple' ? (
<>
<FormField
label={t('agents.workflow.builder.variable')}
labelSurface="background"
>
<Input
type="text"
className="font-mono"
value={parsed.variable}
onChange={(e) =>
updateCase(idx, {
expression: buildSimpleCel(
e.target.value,
parsed.operator,
parsed.value,
),
})
}
placeholder={t(
'agents.workflow.builder.variablePlaceholder',
)}
/>
</FormField>
<div className="flex gap-2">
<FormField
label={t('agents.workflow.builder.operator')}
labelSurface="background"
className="w-32 shrink-0"
>
<Select
value={parsed.operator}
onValueChange={(op) =>
updateCase(idx, {
expression: buildSimpleCel(
parsed.variable,
op,
parsed.value,
),
})
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="==">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="contains">
{t('agents.workflow.builder.opContains')}
</SelectItem>
<SelectItem value="startsWith">
{t('agents.workflow.builder.opStartsWith')}
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.value')}
labelSurface="background"
className="min-w-0 flex-1"
>
<Input
type="text"
value={parsed.value}
onChange={(e) =>
updateCase(idx, {
expression: buildSimpleCel(
parsed.variable,
parsed.operator,
e.target.value,
),
})
}
placeholder={t(
'agents.workflow.builder.valuePlaceholder',
)}
/>
</FormField>
</div>
</>
) : (
<FormField
label={t('agents.workflow.conditionRow', { index: idx + 1 })}
labelSurface="background"
hint={
<>
<Trans
i18nKey="agents.workflow.builder.celHint"
components={{ code: <code /> }}
values={{ braced: '{{query}}' }}
/>{' '}
<Button
variant="link"
size="inline"
asChild
// eslint-disable-next-line shadcn/no-restyle -- a link in a 12px hint keeps the sentence's size and weight
className="text-xs font-normal"
>
<a
href="https://cel.dev/"
target="_blank"
rel="noreferrer"
>
{t('agents.workflow.builder.learnMore')}
</a>
</Button>
</>
}
>
<Textarea
value={c.expression}
onChange={(e) =>
updateCase(idx, { expression: e.target.value })
}
rows={2}
placeholder={t(
'agents.workflow.builder.conditionPlaceholder',
)}
/>
</FormField>
)}
</Card>
);
})}
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={addCase}
className="self-start"
>
<Plus />
{t('agents.workflow.builder.addCondition')}
</Button>
</div>
);
}
@@ -0,0 +1,123 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import NodePanel from './NodePanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const agentNode: Node = {
id: 'classify',
type: 'agent',
position: { x: 0, y: 0 },
data: { title: 'Classify request' },
};
const startNode: Node = {
id: 'start',
type: 'start',
position: { x: 0, y: 0 },
data: { label: 'Start' },
};
describe('NodePanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (node: Node) => {
const handlers = {
onClose: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
onUpdate: vi.fn(),
};
act(() => {
root.render(
<NodePanel node={node} {...handlers}>
<div data-testid="body">body</div>
</NodePanel>,
);
});
return handlers;
};
const menuTrigger = () =>
container.querySelector(
'[aria-label="agents.workflow.builder.nodeActions"]',
) as HTMLButtonElement | null;
it('shows the icon, own title, type badge and id', () => {
render(agentNode);
const header = container.querySelector('header')!;
expect(header.textContent).toContain('Classify request');
const badge = header.querySelector('[data-slot="badge"]')!;
expect(badge.textContent).toBe('agents.workflow.builder.aiAgent');
expect(badge.getAttribute('data-variant')).toBe('neutral');
expect(header.textContent).toContain('classify');
expect(header.querySelector('.bg-secondary')).not.toBeNull();
});
it('offers Duplicate and Delete node in the node menu', () => {
const { onDuplicate, onDelete } = render(agentNode);
const trigger = menuTrigger();
expect(trigger).not.toBeNull();
act(() => {
trigger!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
const items = Array.from(
document.querySelectorAll('[role="menuitem"]'),
) as HTMLElement[];
expect(items.map((i) => i.textContent)).toEqual([
'agents.workflow.builder.duplicateNode',
'agents.workflow.builder.deleteNode',
]);
expect(items[1].getAttribute('data-variant')).toBe('destructive');
act(() => items[0].click());
expect(onDuplicate).toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
});
it('has no node menu and no title field for the start node', () => {
render(startNode);
expect(menuTrigger()).toBeNull();
expect(container.querySelector('input')).toBeNull();
expect(container.textContent).not.toContain('cannotDeleteStart');
});
it('closes from the header', () => {
const { onClose } = render(agentNode);
const close = container.querySelector(
'[aria-label="agents.close"]',
) as HTMLButtonElement;
act(() => close.click());
expect(onClose).toHaveBeenCalled();
});
it('edits the title on the background surface and renders the body', () => {
const { onUpdate } = render(agentNode);
const label = container.querySelector(
'[data-slot="form-field-label"]',
) as HTMLElement;
expect(label.className).toContain('bg-background');
expect(container.querySelector('[data-testid="body"]')).not.toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,151 @@
import { Copy, Trash2, X } from 'lucide-react';
import { type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { type Node } from 'reactflow';
import { Badge } from '@/components/ui/badge';
import { ActionMenu } from '@/components/ui/dropdown-menu';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import CopyButton from '../../../components/CopyButton';
import { NODE_META, nodeToneClass, type WorkflowNodeType } from '../nodeTones';
interface NodePanelProps {
/** The node being edited. */
node: Node;
onClose: () => void;
onDuplicate: () => void;
onDelete: () => void;
/** Merge fields into the node's data (used by the title field). */
onUpdate: (data: Record<string, unknown>) => void;
/** The type's own settings (AgentPanel, ConditionPanel, …). */
children?: ReactNode;
}
// Start and End have fixed names and no settings of their own.
const FIXED_TYPES = new Set(['start', 'end']);
const FIXED_HINT_KEYS: Record<string, string> = {
start: 'agents.workflow.nodes.startHint',
end: 'agents.workflow.nodes.endHint',
};
/**
* The node settings column docked at the canvas's right edge: a header with
* the type's icon, the node's title, its type and id, a ⋯ menu (Duplicate,
* Delete node; none for Start) and close, then the one scrolling body.
*/
export default function NodePanel({
node,
onClose,
onDuplicate,
onDelete,
onUpdate,
children,
}: NodePanelProps) {
const { t } = useTranslation();
const meta = NODE_META[node.type as WorkflowNodeType];
const Icon = meta?.icon;
const typeLabel = meta ? t(meta.labelKey) : (node.type ?? '');
const fixed = FIXED_TYPES.has(node.type ?? '');
const title = fixed
? typeLabel
: node.data.title || node.data.label || typeLabel;
return (
<aside className="bg-background border-border flex w-96 shrink-0 flex-col border-l">
<header className="border-border flex items-start gap-3 border-b px-4 py-3">
<span
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md',
nodeToneClass(node.type),
)}
>
{Icon && <Icon className="size-4" aria-hidden="true" />}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-2">
<h2
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</h2>
{title !== typeLabel && (
<Badge variant="neutral">{typeLabel}</Badge>
)}
</div>
<div className="flex min-w-0 items-center gap-1">
<span
className="text-muted-foreground truncate font-mono text-xs"
title={node.id}
>
{node.id}
</span>
<CopyButton
textToCopy={node.id}
size="xs"
copyLabel={t('agents.workflow.builder.copyNodeId')}
side="bottom"
/>
</div>
</div>
{node.type !== 'start' && (
<ActionMenu
size="toolbar"
triggerLabel={t('agents.workflow.builder.nodeActions')}
options={[
{
label: t('agents.workflow.builder.duplicateNode'),
icon: Copy,
onClick: onDuplicate,
},
{
label: t('agents.workflow.builder.deleteNode'),
icon: Trash2,
variant: 'destructive',
onClick: onDelete,
},
]}
/>
)}
<IconButton
variant="ghost-muted"
size="icon-sm"
side="bottom"
onClick={onClose}
label={t('agents.close')}
icon={X}
/>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col gap-6 p-4">
{fixed ? (
<p className="text-muted-foreground text-sm">
{t(FIXED_HINT_KEYS[node.type ?? ''])}
</p>
) : (
<FormField
label={t('agents.workflow.builder.title')}
labelSurface="background"
>
<Input
type="text"
value={node.data.title || node.data.label || ''}
onChange={(e) =>
onUpdate({ title: e.target.value, label: e.target.value })
}
placeholder={t('agents.workflow.builder.titlePlaceholder')}
/>
</FormField>
)}
{children}
</div>
</div>
</aside>
);
}
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { FormField } from '@/components/ui/form-field';
import { Textarea } from '@/components/ui/textarea';
import { type NodePanelBodyProps } from './types';
/** Settings for a note node: its text. */
export default function NotePanel({
node: selectedNode,
onUpdate: handleUpdateNodeData,
}: NodePanelBodyProps) {
const { t } = useTranslation();
return (
<FormField
labelSurface="background"
label={t('agents.workflow.builder.noteContent')}
>
<Textarea
value={selectedNode.data.content || ''}
onChange={(e) =>
handleUpdateNodeData({
content: e.target.value,
})
}
rows={4}
placeholder={t('agents.workflow.builder.noteContentPlaceholder')}
/>
</FormField>
);
}
@@ -0,0 +1,92 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { index?: number }) =>
opts?.index !== undefined ? `${key}:${opts.index}` : key,
}),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
import StatePanel from './StatePanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const node: Node = {
id: 'state_1',
type: 'state',
position: { x: 0, y: 0 },
data: {
title: 'Set priority',
config: {
operations: [
{ expression: '"P1"', target_variable: 'priority' },
{ expression: '"4h"', target_variable: 'sla' },
],
},
},
};
describe('StatePanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = () => {
const onUpdate = vi.fn();
act(() => {
root.render(<StatePanel node={node} onUpdate={onUpdate} />);
});
return { onUpdate };
};
it('explains CEL once, in the intro', () => {
render();
const matches = container.textContent!.match(
/agents\.workflow\.builder\.celHint/g,
);
expect(matches).toHaveLength(1);
expect(
container.querySelectorAll('a[href="https://cel.dev/"]'),
).toHaveLength(1);
});
it('numbers each assignment and puts the variable before the value', () => {
render();
expect(container.textContent).toContain(
'agents.workflow.builder.assignment:1',
);
expect(container.textContent).toContain(
'agents.workflow.builder.assignment:2',
);
const labels = Array.from(
container.querySelectorAll('[data-slot="form-field-label"]'),
).map((l) => l.textContent);
expect(labels.slice(0, 2)).toEqual([
'agents.workflow.builder.variable',
'agents.workflow.builder.valueCel',
]);
});
it('adds an assignment with an outline pill button', () => {
const { onUpdate } = render();
const add = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.addAssignment'),
)!;
expect(add.getAttribute('data-variant')).toBe('outline');
act(() => add.click());
expect(onUpdate.mock.calls[0][0].config.operations).toHaveLength(3);
});
});
@@ -0,0 +1,132 @@
import { Plus, Trash2 } from 'lucide-react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { SectionHeader } from '@/components/ui/section-header';
import { Textarea } from '@/components/ui/textarea';
import { StateOperationConfig } from '../../types/workflow';
import { NO_ESCAPE } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
/**
* Settings for a Set State node: an intro that explains CEL once, then one
* numbered row box per assignment (variable, then its CEL value).
*/
export default function StatePanel({ node, onUpdate }: NodePanelBodyProps) {
const { t } = useTranslation();
const operations: StateOperationConfig[] = node.data.config?.operations || [];
const setOperations = (next: StateOperationConfig[]) =>
onUpdate({ config: { ...(node.data.config || {}), operations: next } });
const updateOperation = (
idx: number,
patch: Partial<StateOperationConfig>,
) => {
const next = [...operations];
next[idx] = { ...next[idx], ...patch };
setOperations(next);
};
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.stateIntro')}{' '}
<Trans
i18nKey="agents.workflow.builder.celHint"
components={{ code: <code /> }}
values={{ braced: '{{query}}' }}
/>{' '}
<Button variant="link" size="inline" asChild>
<a href="https://cel.dev/" target="_blank" rel="noreferrer">
{t('agents.workflow.builder.learnMore')}
</a>
</Button>
</p>
{operations.map((op, idx) => (
<Card key={idx} variant="subtle" padding="sm" className="gap-4">
<div className="flex items-center gap-2">
<SectionHeader
as="h4"
size="xs"
className="flex-1"
title={t('agents.workflow.builder.assignment', {
index: idx + 1,
})}
/>
{operations.length > 1 && (
<IconButton
variant="ghost-destructive"
size="icon-xs"
label={t('agents.workflow.removeAssignment', {
index: idx + 1,
})}
icon={Trash2}
onClick={() =>
setOperations(operations.filter((_, i) => i !== idx))
}
/>
)}
</div>
<FormField
label={t('agents.workflow.builder.variable')}
labelSurface="background"
>
<Input
type="text"
className="font-mono"
value={op.target_variable}
onChange={(e) =>
updateOperation(idx, { target_variable: e.target.value })
}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'variable_name',
})}
/>
</FormField>
<FormField
label={t('agents.workflow.builder.valueCel')}
labelSurface="background"
>
<Textarea
className="font-mono"
value={op.expression}
onChange={(e) =>
updateOperation(idx, { expression: e.target.value })
}
rows={2}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'query',
})}
/>
</FormField>
</Card>
))}
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() =>
setOperations([
...operations,
{ expression: '', target_variable: '' },
])
}
className="self-start"
>
<Plus />
{t('agents.workflow.builder.addAssignment')}
</Button>
</div>
);
}
@@ -0,0 +1,12 @@
import { type Node } from 'reactflow';
/** Props every node settings panel body takes. */
export interface NodePanelBodyProps {
/** The node being edited. */
node: Node;
/** Merge fields into the node's data (a snapshot first unless disabled). */
onUpdate: (
data: Record<string, unknown>,
options?: { snapshot?: boolean },
) => void;
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { findFreePosition } from './workflowHelpers';
const box = (id: string, x: number, y: number, width = 200, height = 60) => ({
id,
position: { x, y },
width,
height,
});
describe('findFreePosition', () => {
it('keeps the wanted spot when nothing is there', () => {
expect(findFreePosition([box('a', 0, 0)], { x: 400, y: 0 })).toEqual({
x: 400,
y: 0,
});
});
it('moves down past a node that covers the spot', () => {
const spot = findFreePosition([box('a', 380, 0)], { x: 400, y: 0 });
expect(spot.x).toBe(400);
expect(spot.y).toBeGreaterThanOrEqual(60 + 24);
});
it('keeps moving down past a column of nodes', () => {
const nodes = [box('a', 400, 0), box('b', 400, 84), box('c', 400, 168)];
const spot = findFreePosition(nodes, { x: 400, y: 0 });
expect(spot.y).toBeGreaterThanOrEqual(168 + 60 + 24);
});
it('uses a default size for nodes React Flow has not measured yet', () => {
const spot = findFreePosition([{ id: 'a', position: { x: 400, y: 0 } }], {
x: 450,
y: 10,
});
expect(spot.y).toBeGreaterThan(10);
});
});
@@ -0,0 +1,204 @@
import { type TFunction } from 'i18next';
import { ConditionCase } from '../types/workflow';
import { FilePassing } from './documentConfig';
// Names and handles are the user's own text: React escapes on render, so
// i18next must not escape them first.
export const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
export interface AgentNodeConfig {
agent_type: 'classic' | 'research';
llm_name?: string;
model_id?: string;
system_prompt: string;
prompt_template: string;
output_variable?: string;
stream_to_user: boolean;
sources: string[];
tools: string[];
chunks?: string;
retriever?: string;
json_schema?: Record<string, unknown>;
input_documents?: string[];
file_passing?: FilePassing;
}
export interface UserTool {
id: string;
name: string;
displayName: string;
customName?: string;
// Workflow-only builtins (e.g. read_document) are kept here; the classic
// agent picker filters them out.
workflow_only?: boolean;
}
/**
* Check an agent node's structured-output schema.
*
* Args:
* schema: The parsed schema, or undefined when none is set.
*
* Returns:
* A short English error fragment, or null when the schema is usable.
*/
export function validateJsonSchemaConfig(schema: unknown): string | null {
if (schema === undefined || schema === null) return null;
if (typeof schema !== 'object' || Array.isArray(schema)) {
return 'must be a valid JSON object';
}
const schemaObject = schema as Record<string, unknown>;
if (!('schema' in schemaObject) && !('type' in schemaObject)) {
return 'must include either a "type" or "schema" field';
}
return null;
}
// The schema validators return short English fragments (tests and the
// validation list key off them); these map each to its locale key.
const SCHEMA_ERROR_KEYS: Record<string, string> = {
'must be a valid JSON object': 'agents.workflow.schema.notObject',
'must include either a "type" or "schema" field':
'agents.workflow.schema.missingType',
'must be valid JSON': 'agents.workflow.schema.invalidJson',
};
/**
* Translate a JSON schema validation fragment.
*
* Args:
* t: The i18next translate function.
* fragment: The fragment a schema validator returned.
*
* Returns:
* The translated fragment, or the fragment itself when it is unknown.
*/
export function schemaErrorText(t: TFunction, fragment: string): string {
const key = SCHEMA_ERROR_KEYS[fragment];
return key ? t(key) : fragment;
}
/**
* Give every condition case a unique `case_N` source handle.
*
* Args:
* cases: The cases as stored.
*
* Returns:
* The cases, with missing or duplicate handles replaced.
*/
export function normalizeConditionCases(
cases: ConditionCase[],
): ConditionCase[] {
const usedHandles = new Set<string>();
let nextIndex = 0;
return cases.map((conditionCase) => {
const candidate = (conditionCase.sourceHandle || '').trim();
if (candidate && !usedHandles.has(candidate)) {
usedHandles.add(candidate);
const match = candidate.match(/^case_(\d+)$/);
if (match) {
nextIndex = Math.max(nextIndex, Number(match[1]) + 1);
}
return conditionCase;
}
while (usedHandles.has(`case_${nextIndex}`)) {
nextIndex += 1;
}
const generatedHandle = `case_${nextIndex}`;
usedHandles.add(generatedHandle);
nextIndex += 1;
return {
...conditionCase,
sourceHandle: generatedHandle,
};
});
}
/**
* The next free `case_N` handle for a new condition case.
*
* Args:
* cases: The existing cases.
*
* Returns:
* An unused handle.
*/
export function getNextConditionHandle(cases: ConditionCase[]): string {
const usedHandles = new Set(
cases.map((conditionCase) => conditionCase.sourceHandle).filter(Boolean),
);
const usedIndices = Array.from(usedHandles)
.map((handle) => handle.match(/^case_(\d+)$/))
.filter((match): match is RegExpMatchArray => Boolean(match))
.map((match) => Number(match[1]));
let nextIndex = usedIndices.length > 0 ? Math.max(...usedIndices) + 1 : 0;
while (usedHandles.has(`case_${nextIndex}`)) {
nextIndex += 1;
}
return `case_${nextIndex}`;
}
interface PlacedNode {
id: string;
position: { x: number; y: number };
width?: number | null;
height?: number | null;
}
// Nodes React Flow hasn't measured yet count as a typical node.
const DEFAULT_NODE_WIDTH = 200;
const DEFAULT_NODE_HEIGHT = 64;
const NODE_GAP = 24;
const MAX_PLACEMENT_STEPS = 50;
/**
* Find a spot for a new node that doesn't cover an existing one.
*
* Starts at `wanted` (the new node's top-left corner) and steps straight down
* until the new node's box, with a gap around it, clears every other node.
*
* Args:
* nodes: The nodes already on the canvas.
* wanted: Where the new node would ideally go.
* size: The new node's expected size.
*
* Returns:
* The first free top-left position at or below `wanted`.
*/
export function findFreePosition(
nodes: PlacedNode[],
wanted: { x: number; y: number },
size: { width: number; height: number } = {
width: DEFAULT_NODE_WIDTH,
height: DEFAULT_NODE_HEIGHT,
},
): { x: number; y: number } {
const overlapping = (y: number) =>
nodes.find((node) => {
const width = node.width ?? DEFAULT_NODE_WIDTH;
const height = node.height ?? DEFAULT_NODE_HEIGHT;
return (
wanted.x < node.position.x + width + NODE_GAP &&
wanted.x + size.width + NODE_GAP > node.position.x &&
y < node.position.y + height + NODE_GAP &&
y + size.height + NODE_GAP > node.position.y
);
});
let y = wanted.y;
for (let step = 0; step < MAX_PLACEMENT_STEPS; step += 1) {
const blocker = overlapping(y);
if (!blocker) break;
y = blocker.position.y + (blocker.height ?? DEFAULT_NODE_HEIGHT) + NODE_GAP;
}
return { x: wanted.x, y };
}
+4
View File
@@ -22,6 +22,8 @@ interface FileUploadProps {
previewSize?: number;
/** `compact` is a one-row target for forms and panels. */
size?: 'default' | 'compact' | 'tile';
/** A tile's width; `fixed` keeps it 64px in a narrow drawer. */
tileSize?: 'responsive' | 'fixed';
/** An image already saved (an agent's avatar), shown in a tile until a new one is picked. */
currentImage?: string;
@@ -55,6 +57,7 @@ export const FileUpload = ({
showPreview = false,
previewSize = 80,
size = 'default',
tileSize,
currentImage,
children,
className,
@@ -256,6 +259,7 @@ export const FileUpload = ({
maxSize={maxSize}
disabled={disabled}
size={size}
tileSize={tileSize}
title={title}
description={description}
icon={<ImageUp />}
@@ -56,6 +56,18 @@ describe('Dropzone', () => {
expect(html).not.toContain('hidden</span>');
});
it('keeps a fixed tile at 64px and icon-only at every width', () => {
const html = renderToStaticMarkup(
<Dropzone onDrop={vi.fn()} size="tile" tileSize="fixed" title="Avatar" />,
);
expect(html).toContain('data-tile-size="fixed"');
expect(html).toContain('size-16');
expect(html).not.toContain('sm:size-22');
expect(html).not.toContain('sm:not-sr-only');
// The word is still the target's accessible name.
expect(html).toMatch(/class="[^"]*\bsr-only\b[^"]*">Avatar/);
});
it('never uses raw palette colours', () => {
const html = renderToStaticMarkup(<Dropzone onDrop={vi.fn()} />);
expect(html).not.toMatch(/\b(bg|text|border)-(gray|red|green|blue)-\d+/);
+23 -4
View File
@@ -22,10 +22,17 @@ const dropzoneVariants = cva(
// An 88px square beside a form's fields (an agent's avatar): the icon
// and a one-word label, or the picked image filling it.
// 64px and icon-only on a phone (beside the Name field), 88px from sm.
tile: 'size-16 shrink-0 gap-1 rounded-2xl p-2 sm:size-22',
tile: 'size-16 shrink-0 gap-1 rounded-2xl p-2',
},
// Only for size tile. responsive grows to 88px from sm; fixed stays
// 64px at every width, for a tile in a narrow drawer, where the
// breakpoint follows the window, not the drawer.
tileSize: { responsive: '', fixed: '' },
},
defaultVariants: { size: 'default' },
compoundVariants: [
{ size: 'tile', tileSize: 'responsive', class: 'sm:size-22' },
],
defaultVariants: { size: 'default', tileSize: 'responsive' },
},
);
@@ -57,6 +64,11 @@ type DropzoneProps = {
* tile is an 88px square that shows only the icon and the title.
*/
size?: 'default' | 'compact' | 'tile';
/**
* A tile's width: `responsive` (64px on a phone, 88px with its title from
* sm) or `fixed` (64px and icon-only everywhere, for a narrow drawer).
*/
tileSize?: 'responsive' | 'fixed';
/** Main line. Defaults to the generic upload prompt. */
title?: React.ReactNode;
/** Secondary line for accepted types and limits, e.g. ".yaml, up to 5 MB". */
@@ -84,6 +96,7 @@ function Dropzone({
maxSize,
disabled = false,
size = 'default',
tileSize = 'responsive',
title = 'Click to upload or drag and drop',
description,
icon,
@@ -116,10 +129,11 @@ function Dropzone({
aria-describedby={field['aria-describedby']}
data-slot="dropzone"
data-size={size}
data-tile-size={size === 'tile' ? tileSize : undefined}
data-disabled={disabled || undefined}
data-drag-active={isDragActive || undefined}
data-drag-reject={isDragReject || undefined}
className={cn(dropzoneVariants({ size }))}
className={cn(dropzoneVariants({ size, tileSize }))}
>
<input {...getInputProps({ id: field.id })} />
{children ??
@@ -128,7 +142,12 @@ function Dropzone({
<span className={dropzoneIconVariants({ size })}>
{icon ?? <CloudUpload />}
</span>
<span className="text-muted-foreground sr-only text-xs sm:not-sr-only">
<span
className={cn(
'text-muted-foreground sr-only text-xs',
tileSize === 'responsive' && 'sm:not-sr-only',
)}
>
{title}
</span>
</>
+12
View File
@@ -2172,6 +2172,18 @@ export default function DesignSystem() {
name and description.
</span>
</div>
<div className="flex items-center gap-3">
<Dropzone
size="tile"
tileSize="fixed"
onDrop={(files) => setDropped(files.map((f) => f.name))}
title="Avatar"
/>
<span className="text-muted-foreground text-xs">
tileSize=&quot;fixed&quot;: 64px and icon-only at every
width, for a narrow drawer.
</span>
</div>
<p className="text-muted-foreground text-xs">
Last drop:{' '}
{dropped.length ? dropped.join(', ') : 'nothing yet'}
+28 -10
View File
@@ -1963,15 +1963,8 @@
"saveFailed": "Workflow konnte nicht gespeichert werden",
"defaultDescription": "Workflow-Agent: {{name}}",
"newWorkflow": "Neuer Workflow",
"editDetails": "Workflow-Details bearbeiten",
"workflowName": "Workflow-Name",
"workflowNamePlaceholder": "Workflow-Namen eingeben",
"workflowDescriptionPlaceholder": "Beschreibe, was dieser Workflow macht",
"agentImageAlt": "Agentenbild",
"currentImage": "Aktuelles Bild",
"agentImage": "Agentenbild",
"agentImageHint": "Bildänderungen werden beim nächsten Speichern übernommen.",
"done": "Fertig",
"unablePreview": "Workflow-Vorschau nicht möglich",
"unableSave": "Workflow kann nicht gespeichert werden",
"unablePublish": "Workflow kann nicht veröffentlicht werden",
@@ -1981,6 +1974,22 @@
"setStateHint": "Workflow-Variablen ändern",
"conditionHint": "Bedingte Verzweigung",
"codeHint": "Code in einer Sandbox ausführen",
"paletteHint": "Auf die Arbeitsfläche ziehen oder zum Hinzufügen klicken.",
"nodeActions": "Knotenaktionen",
"duplicateNode": "Duplizieren",
"copyNodeId": "Knoten-ID kopieren",
"knowledge": "Wissen",
"output": "Ausgabe",
"streamToUserDescription": "Die Antwort dieses Knotens beim Schreiben im Chat anzeigen",
"advancedSettings": "Erweiterte Einstellungen",
"conditionMode": "Bedingungsmodus",
"branchName": "Name des Zweigs",
"variable": "Variable",
"value": "Wert",
"valueCel": "Wert (CEL)",
"addCondition": "Bedingung hinzufügen",
"assignment": "Zuweisung {{index}}",
"addAssignment": "Zuweisung hinzufügen",
"startNode": "Startknoten",
"endNode": "Endknoten",
"stateTitle": "Globale Variablen setzen",
@@ -2009,7 +2018,7 @@
"invalidSchema": "Ungültiges JSON-Schema: {{error}}",
"noteContent": "Notizinhalt",
"noteContentPlaceholder": "Notizinhalt eingeben",
"stateIntro": "Den Zustandsvariablen des Workflows Werte zuweisen",
"stateIntro": "Den Zustandsvariablen des Workflows Werte zuweisen.",
"assignValue": "Wert zuweisen",
"celHint": "Erstelle mit der Common Expression Language einen eigenen Ausdruck. Verweise auf den Zustand mit dem reinen Namen (<code>query</code>), nicht <code>{{braced}}</code>.",
"learnMore": "Mehr erfahren",
@@ -2020,6 +2029,8 @@
"caseNamePlaceholder": "Fallname (optional)",
"variablePlaceholder": "Variable",
"valuePlaceholder": "Wert",
"operator": "Operator",
"examplePlaceholder": "z. B. {{example}}",
"opContains": "enthält",
"opStartsWith": "beginnt",
"conditionPlaceholder": "Bedingung eingeben, z. B. query.contains(\"refund\")",
@@ -2031,7 +2042,11 @@
"cannotDeleteStart": "Startknoten kann nicht gelöscht werden",
"deleteNode": "Knoten löschen",
"deleteConfirm": "Möchtest du „{{name}}“ wirklich löschen?",
"deleteConfirmUnnamed": "Möchtest du diesen Workflow-Agenten wirklich löschen?"
"deleteConfirmUnnamed": "Möchtest du diesen Workflow-Agenten wirklich löschen?",
"detailsTitle": "Workflow-Details",
"detailsDescription": "Name, Beschreibung und Bild, die auf der Agentenkarte angezeigt werden.",
"unsavedChanges": "Nicht gespeicherte Änderungen",
"editDetailsMenu": "Details bearbeiten"
},
"validation": {
"nameRequired": "Workflow-Name ist erforderlich",
@@ -2124,7 +2139,10 @@
"searchPlaceholder": "Variablen suchen...",
"empty": "Keine Variablen gefunden",
"addContext": "Kontext hinzufügen"
}
},
"zoomIn": "Vergrößern",
"zoomOut": "Verkleinern",
"fitView": "Ansicht anpassen"
},
"typeModal": {
"title": "Neuen Agenten erstellen",
+28 -11
View File
@@ -1969,12 +1969,14 @@
"conditionRow": "Condition {{index}}",
"conditionNameRow": "Condition {{index}} name",
"conditionVariableRow": "Condition {{index}} variable",
"conditionOperatorRow": "Condition {{index}} operator",
"conditionValueRow": "Condition {{index}} value",
"undo": "Undo",
"undoHint": "Undo (Ctrl+Z)",
"redo": "Redo",
"redoHint": "Redo (Ctrl+Shift+Z)",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"fitView": "Fit view",
"removeAssignment": "Remove assignment {{index}}",
"removeCondition": "Remove condition {{index}}",
"artifacts": {
@@ -1997,15 +1999,8 @@
"saveFailed": "Failed to save workflow",
"defaultDescription": "Workflow agent: {{name}}",
"newWorkflow": "New Workflow",
"editDetails": "Edit workflow details",
"workflowName": "Workflow Name",
"workflowNamePlaceholder": "Enter workflow name",
"workflowDescriptionPlaceholder": "Describe what this workflow does",
"agentImageAlt": "Agent image",
"currentImage": "Current image",
"agentImage": "Agent Image",
"agentImageHint": "Image updates are included the next time you save.",
"done": "Done",
"unablePreview": "Unable to preview workflow",
"unableSave": "Unable to save workflow",
"unablePublish": "Unable to publish workflow",
@@ -2015,6 +2010,22 @@
"setStateHint": "Modify workflow variables",
"conditionHint": "Conditional branching",
"codeHint": "Run code in a sandbox",
"paletteHint": "Drag onto the canvas, or click to add.",
"nodeActions": "Node actions",
"duplicateNode": "Duplicate",
"copyNodeId": "Copy node ID",
"knowledge": "Knowledge",
"output": "Output",
"streamToUserDescription": "Show this node's answer in the chat as it's written",
"advancedSettings": "Advanced settings",
"conditionMode": "Condition mode",
"branchName": "Branch name",
"variable": "Variable",
"value": "Value",
"valueCel": "Value (CEL)",
"addCondition": "Add condition",
"assignment": "Assignment {{index}}",
"addAssignment": "Add assignment",
"startNode": "Start Node",
"endNode": "End Node",
"stateTitle": "Set global variables",
@@ -2043,7 +2054,7 @@
"invalidSchema": "Invalid JSON schema: {{error}}",
"noteContent": "Note Content",
"noteContentPlaceholder": "Enter note content",
"stateIntro": "Assign values to workflow's state variables",
"stateIntro": "Assign values to the workflow's state variables.",
"assignValue": "Assign value",
"celHint": "Use Common Expression Language to create a custom expression. Reference state by bare name (<code>query</code>), not <code>{{braced}}</code>.",
"learnMore": "Learn more",
@@ -2054,6 +2065,8 @@
"caseNamePlaceholder": "Case name (optional)",
"variablePlaceholder": "Variable",
"valuePlaceholder": "Value",
"operator": "Operator",
"examplePlaceholder": "e.g. {{example}}",
"opContains": "contains",
"opStartsWith": "starts",
"conditionPlaceholder": "Enter condition, e.g. query.contains(\"refund\")",
@@ -2063,9 +2076,13 @@
"timeout": "Timeout (seconds)",
"optional": "Optional",
"cannotDeleteStart": "Cannot Delete Start Node",
"deleteNode": "Delete Node",
"deleteNode": "Delete node",
"deleteConfirm": "Are you sure you want to delete \"{{name}}\"?",
"deleteConfirmUnnamed": "Are you sure you want to delete \"this workflow agent\"?"
"deleteConfirmUnnamed": "Are you sure you want to delete \"this workflow agent\"?",
"detailsTitle": "Workflow details",
"detailsDescription": "Name, description and image shown on the agent card.",
"unsavedChanges": "Unsaved changes",
"editDetailsMenu": "Edit details"
},
"validation": {
"nameRequired": "Workflow name is required",
+28 -10
View File
@@ -1963,15 +1963,8 @@
"saveFailed": "No se pudo guardar el flujo de trabajo",
"defaultDescription": "Agente de flujo de trabajo: {{name}}",
"newWorkflow": "Nuevo flujo de trabajo",
"editDetails": "Editar detalles del flujo de trabajo",
"workflowName": "Nombre del flujo de trabajo",
"workflowNamePlaceholder": "Introduce el nombre del flujo de trabajo",
"workflowDescriptionPlaceholder": "Describe qué hace este flujo de trabajo",
"agentImageAlt": "Imagen del agente",
"currentImage": "Imagen actual",
"agentImage": "Imagen del agente",
"agentImageHint": "Los cambios de imagen se incluyen la próxima vez que guardes.",
"done": "Listo",
"unablePreview": "No se puede previsualizar el flujo de trabajo",
"unableSave": "No se puede guardar el flujo de trabajo",
"unablePublish": "No se puede publicar el flujo de trabajo",
@@ -1981,6 +1974,22 @@
"setStateHint": "Modifica las variables del flujo de trabajo",
"conditionHint": "Ramificación condicional",
"codeHint": "Ejecuta código en un entorno aislado",
"paletteHint": "Arrástralo al lienzo o haz clic para añadirlo.",
"nodeActions": "Acciones del nodo",
"duplicateNode": "Duplicar",
"copyNodeId": "Copiar ID del nodo",
"knowledge": "Conocimiento",
"output": "Salida",
"streamToUserDescription": "Muestra la respuesta de este nodo en el chat mientras se escribe",
"advancedSettings": "Configuración avanzada",
"conditionMode": "Modo de condición",
"branchName": "Nombre de la rama",
"variable": "Variable",
"value": "Valor",
"valueCel": "Valor (CEL)",
"addCondition": "Añadir condición",
"assignment": "Asignación {{index}}",
"addAssignment": "Añadir asignación",
"startNode": "Nodo de inicio",
"endNode": "Nodo final",
"stateTitle": "Establecer variables globales",
@@ -2009,7 +2018,7 @@
"invalidSchema": "Esquema JSON no válido: {{error}}",
"noteContent": "Contenido de la nota",
"noteContentPlaceholder": "Introduce el contenido de la nota",
"stateIntro": "Asigna valores a las variables de estado del flujo de trabajo",
"stateIntro": "Asigna valores a las variables de estado del flujo de trabajo.",
"assignValue": "Asignar valor",
"celHint": "Usa Common Expression Language para crear una expresión personalizada. Haz referencia al estado por su nombre sin llaves (<code>query</code>), no <code>{{braced}}</code>.",
"learnMore": "Más información",
@@ -2020,6 +2029,8 @@
"caseNamePlaceholder": "Nombre del caso (opcional)",
"variablePlaceholder": "Variable",
"valuePlaceholder": "Valor",
"operator": "Operador",
"examplePlaceholder": "p. ej. {{example}}",
"opContains": "contiene",
"opStartsWith": "empieza",
"conditionPlaceholder": "Introduce una condición, p. ej. query.contains(\"refund\")",
@@ -2031,7 +2042,11 @@
"cannotDeleteStart": "No se puede eliminar el nodo de inicio",
"deleteNode": "Eliminar nodo",
"deleteConfirm": "¿Seguro que quieres eliminar «{{name}}»?",
"deleteConfirmUnnamed": "¿Seguro que quieres eliminar este agente de flujo de trabajo?"
"deleteConfirmUnnamed": "¿Seguro que quieres eliminar este agente de flujo de trabajo?",
"detailsTitle": "Detalles del flujo de trabajo",
"detailsDescription": "Nombre, descripción e imagen que se muestran en la tarjeta del agente.",
"unsavedChanges": "Cambios sin guardar",
"editDetailsMenu": "Editar detalles"
},
"validation": {
"nameRequired": "El nombre del flujo de trabajo es obligatorio",
@@ -2124,7 +2139,10 @@
"searchPlaceholder": "Buscar variables...",
"empty": "No se encontraron variables",
"addContext": "Añadir contexto"
}
},
"zoomIn": "Acercar",
"zoomOut": "Alejar",
"fitView": "Ajustar vista"
},
"typeModal": {
"title": "Crear un agente nuevo",
+28 -10
View File
@@ -1958,15 +1958,8 @@
"saveFailed": "ワークフローを保存できませんでした",
"defaultDescription": "ワークフローエージェント: {{name}}",
"newWorkflow": "新しいワークフロー",
"editDetails": "ワークフローの詳細を編集",
"workflowName": "ワークフロー名",
"workflowNamePlaceholder": "ワークフロー名を入力",
"workflowDescriptionPlaceholder": "このワークフローの内容を説明してください",
"agentImageAlt": "エージェント画像",
"currentImage": "現在の画像",
"agentImage": "エージェント画像",
"agentImageHint": "画像の変更は次回の保存時に反映されます。",
"done": "完了",
"unablePreview": "ワークフローをプレビューできません",
"unableSave": "ワークフローを保存できません",
"unablePublish": "ワークフローを公開できません",
@@ -1976,6 +1969,22 @@
"setStateHint": "ワークフロー変数を変更",
"conditionHint": "条件分岐",
"codeHint": "サンドボックスでコードを実行",
"paletteHint": "キャンバスにドラッグするか、クリックして追加します。",
"nodeActions": "ノードの操作",
"duplicateNode": "複製",
"copyNodeId": "ノード ID をコピー",
"knowledge": "ナレッジ",
"output": "出力",
"streamToUserDescription": "このノードの回答を生成中にチャットへ表示します",
"advancedSettings": "詳細設定",
"conditionMode": "条件モード",
"branchName": "分岐名",
"variable": "変数",
"value": "値",
"valueCel": "値 (CEL)",
"addCondition": "条件を追加",
"assignment": "割り当て {{index}}",
"addAssignment": "割り当てを追加",
"startNode": "開始ノード",
"endNode": "終了ノード",
"stateTitle": "グローバル変数を設定",
@@ -2004,7 +2013,7 @@
"invalidSchema": "無効な JSON スキーマ: {{error}}",
"noteContent": "メモの内容",
"noteContentPlaceholder": "メモの内容を入力",
"stateIntro": "ワークフローの状態変数に値を割り当てます",
"stateIntro": "ワークフローの状態変数に値を割り当てます。",
"assignValue": "値を割り当て",
"celHint": "Common Expression Language でカスタム式を作成します。状態は名前だけで参照します(<code>query</code>)。<code>{{braced}}</code> ではありません。",
"learnMore": "詳細",
@@ -2015,6 +2024,8 @@
"caseNamePlaceholder": "ケース名(任意)",
"variablePlaceholder": "変数",
"valuePlaceholder": "値",
"operator": "演算子",
"examplePlaceholder": "例: {{example}}",
"opContains": "含む",
"opStartsWith": "で始まる",
"conditionPlaceholder": "条件を入力(例: query.contains(\"refund\"))",
@@ -2026,7 +2037,11 @@
"cannotDeleteStart": "開始ノードは削除できません",
"deleteNode": "ノードを削除",
"deleteConfirm": "「{{name}}」を削除してもよろしいですか?",
"deleteConfirmUnnamed": "このワークフローエージェントを削除してもよろしいですか?"
"deleteConfirmUnnamed": "このワークフローエージェントを削除してもよろしいですか?",
"detailsTitle": "ワークフローの詳細",
"detailsDescription": "エージェントカードに表示される名前、説明、画像です。",
"unsavedChanges": "未保存の変更",
"editDetailsMenu": "詳細を編集"
},
"validation": {
"nameRequired": "ワークフロー名は必須です",
@@ -2119,7 +2134,10 @@
"searchPlaceholder": "変数を検索...",
"empty": "変数が見つかりません",
"addContext": "コンテキストを追加"
}
},
"zoomIn": "拡大",
"zoomOut": "縮小",
"fitView": "全体を表示"
},
"typeModal": {
"title": "新しいエージェントを作成",
+28 -10
View File
@@ -2017,15 +2017,8 @@
"saveFailed": "Не удалось сохранить рабочий процесс",
"defaultDescription": "Агент рабочего процесса: {{name}}",
"newWorkflow": "Новый рабочий процесс",
"editDetails": "Изменить сведения о рабочем процессе",
"workflowName": "Название рабочего процесса",
"workflowNamePlaceholder": "Введите название рабочего процесса",
"workflowDescriptionPlaceholder": "Опишите, что делает этот рабочий процесс",
"agentImageAlt": "Изображение агента",
"currentImage": "Текущее изображение",
"agentImage": "Изображение агента",
"agentImageHint": "Изменения изображения применятся при следующем сохранении.",
"done": "Готово",
"unablePreview": "Не удалось запустить предпросмотр рабочего процесса",
"unableSave": "Не удалось сохранить рабочий процесс",
"unablePublish": "Не удалось опубликовать рабочий процесс",
@@ -2035,6 +2028,22 @@
"setStateHint": "Изменение переменных рабочего процесса",
"conditionHint": "Условное ветвление",
"codeHint": "Запуск кода в песочнице",
"paletteHint": "Перетащите на холст или нажмите, чтобы добавить.",
"nodeActions": "Действия с узлом",
"duplicateNode": "Дублировать",
"copyNodeId": "Копировать ID узла",
"knowledge": "Знания",
"output": "Вывод",
"streamToUserDescription": "Показывать ответ этого узла в чате по мере написания",
"advancedSettings": "Дополнительные настройки",
"conditionMode": "Режим условия",
"branchName": "Название ветки",
"variable": "Переменная",
"value": "Значение",
"valueCel": "Значение (CEL)",
"addCondition": "Добавить условие",
"assignment": "Присваивание {{index}}",
"addAssignment": "Добавить присваивание",
"startNode": "Начальный узел",
"endNode": "Конечный узел",
"stateTitle": "Задать глобальные переменные",
@@ -2063,7 +2072,7 @@
"invalidSchema": "Некорректная JSON-схема: {{error}}",
"noteContent": "Текст заметки",
"noteContentPlaceholder": "Введите текст заметки",
"stateIntro": "Присвойте значения переменным состояния рабочего процесса",
"stateIntro": "Присвойте значения переменным состояния рабочего процесса.",
"assignValue": "Присвоить значение",
"celHint": "Используйте Common Expression Language, чтобы создать собственное выражение. Ссылайтесь на состояние по имени (<code>query</code>), а не <code>{{braced}}</code>.",
"learnMore": "Подробнее",
@@ -2074,6 +2083,8 @@
"caseNamePlaceholder": "Название варианта (необязательно)",
"variablePlaceholder": "Переменная",
"valuePlaceholder": "Значение",
"operator": "Оператор",
"examplePlaceholder": "напр. {{example}}",
"opContains": "содержит",
"opStartsWith": "начинается",
"conditionPlaceholder": "Введите условие, например query.contains(\"refund\")",
@@ -2085,7 +2096,11 @@
"cannotDeleteStart": "Начальный узел нельзя удалить",
"deleteNode": "Удалить узел",
"deleteConfirm": "Вы уверены, что хотите удалить «{{name}}»?",
"deleteConfirmUnnamed": "Вы уверены, что хотите удалить этого агента рабочего процесса?"
"deleteConfirmUnnamed": "Вы уверены, что хотите удалить этого агента рабочего процесса?",
"detailsTitle": "Сведения о рабочем процессе",
"detailsDescription": "Название, описание и изображение, которые показываются на карточке агента.",
"unsavedChanges": "Несохранённые изменения",
"editDetailsMenu": "Изменить сведения"
},
"validation": {
"nameRequired": "Укажите название рабочего процесса",
@@ -2184,7 +2199,10 @@
"searchPlaceholder": "Поиск переменных...",
"empty": "Переменные не найдены",
"addContext": "Добавить контекст"
}
},
"zoomIn": "Увеличить",
"zoomOut": "Уменьшить",
"fitView": "Уместить на экране"
},
"typeModal": {
"title": "Создать нового агента",
+28 -10
View File
@@ -1958,15 +1958,8 @@
"saveFailed": "無法儲存工作流程",
"defaultDescription": "工作流程代理:{{name}}",
"newWorkflow": "新增工作流程",
"editDetails": "編輯工作流程詳細資料",
"workflowName": "工作流程名稱",
"workflowNamePlaceholder": "輸入工作流程名稱",
"workflowDescriptionPlaceholder": "描述此工作流程的用途",
"agentImageAlt": "代理圖片",
"currentImage": "目前圖片",
"agentImage": "代理圖片",
"agentImageHint": "圖片更新將於下次儲存時生效。",
"done": "完成",
"unablePreview": "無法預覽工作流程",
"unableSave": "無法儲存工作流程",
"unablePublish": "無法發佈工作流程",
@@ -1976,6 +1969,22 @@
"setStateHint": "修改工作流程變數",
"conditionHint": "條件分支",
"codeHint": "在沙箱中執行程式碼",
"paletteHint": "拖曳到畫布上,或點擊新增。",
"nodeActions": "節點操作",
"duplicateNode": "複製",
"copyNodeId": "複製節點 ID",
"knowledge": "知識",
"output": "輸出",
"streamToUserDescription": "在生成過程中將此節點的回答顯示在聊天中",
"advancedSettings": "進階設定",
"conditionMode": "條件模式",
"branchName": "分支名稱",
"variable": "變數",
"value": "值",
"valueCel": "值 (CEL)",
"addCondition": "新增條件",
"assignment": "指定 {{index}}",
"addAssignment": "新增指定",
"startNode": "開始節點",
"endNode": "結束節點",
"stateTitle": "設定全域變數",
@@ -2004,7 +2013,7 @@
"invalidSchema": "JSON Schema 無效:{{error}}",
"noteContent": "備註內容",
"noteContentPlaceholder": "輸入備註內容",
"stateIntro": "為工作流程的狀態變數指定值",
"stateIntro": "為工作流程的狀態變數指定值。",
"assignValue": "指定值",
"celHint": "使用 Common Expression Language 撰寫自訂運算式。直接以名稱參照狀態(<code>query</code>),而非 <code>{{braced}}</code>。",
"learnMore": "瞭解更多",
@@ -2015,6 +2024,8 @@
"caseNamePlaceholder": "分支名稱(選填)",
"variablePlaceholder": "變數",
"valuePlaceholder": "值",
"operator": "運算子",
"examplePlaceholder": "例如 {{example}}",
"opContains": "包含",
"opStartsWith": "開頭為",
"conditionPlaceholder": "輸入條件,例如 query.contains(\"refund\")",
@@ -2026,7 +2037,11 @@
"cannotDeleteStart": "無法刪除開始節點",
"deleteNode": "刪除節點",
"deleteConfirm": "確定要刪除「{{name}}」嗎?",
"deleteConfirmUnnamed": "確定要刪除此工作流程代理嗎?"
"deleteConfirmUnnamed": "確定要刪除此工作流程代理嗎?",
"detailsTitle": "工作流程詳細資料",
"detailsDescription": "顯示在代理卡片上的名稱、說明和圖片。",
"unsavedChanges": "未儲存的變更",
"editDetailsMenu": "編輯詳細資料"
},
"validation": {
"nameRequired": "工作流程名稱為必填",
@@ -2119,7 +2134,10 @@
"searchPlaceholder": "搜尋變數...",
"empty": "找不到變數",
"addContext": "新增內容"
}
},
"zoomIn": "放大",
"zoomOut": "縮小",
"fitView": "符合檢視"
},
"typeModal": {
"title": "建立新代理",
+28 -10
View File
@@ -1958,15 +1958,8 @@
"saveFailed": "无法保存工作流",
"defaultDescription": "工作流智能体:{{name}}",
"newWorkflow": "新建工作流",
"editDetails": "编辑工作流详情",
"workflowName": "工作流名称",
"workflowNamePlaceholder": "输入工作流名称",
"workflowDescriptionPlaceholder": "描述此工作流的用途",
"agentImageAlt": "智能体图片",
"currentImage": "当前图片",
"agentImage": "智能体图片",
"agentImageHint": "图片更新将在下次保存时生效。",
"done": "完成",
"unablePreview": "无法预览工作流",
"unableSave": "无法保存工作流",
"unablePublish": "无法发布工作流",
@@ -1976,6 +1969,22 @@
"setStateHint": "修改工作流变量",
"conditionHint": "条件分支",
"codeHint": "在沙箱中运行代码",
"paletteHint": "拖到画布上,或点击添加。",
"nodeActions": "节点操作",
"duplicateNode": "复制",
"copyNodeId": "复制节点 ID",
"knowledge": "知识",
"output": "输出",
"streamToUserDescription": "在生成过程中将此节点的回答显示在聊天中",
"advancedSettings": "高级设置",
"conditionMode": "条件模式",
"branchName": "分支名称",
"variable": "变量",
"value": "值",
"valueCel": "值 (CEL)",
"addCondition": "添加条件",
"assignment": "赋值 {{index}}",
"addAssignment": "添加赋值",
"startNode": "开始节点",
"endNode": "结束节点",
"stateTitle": "设置全局变量",
@@ -2004,7 +2013,7 @@
"invalidSchema": "JSON Schema 无效:{{error}}",
"noteContent": "备注内容",
"noteContentPlaceholder": "输入备注内容",
"stateIntro": "为工作流的状态变量赋值",
"stateIntro": "为工作流的状态变量赋值。",
"assignValue": "赋值",
"celHint": "使用 Common Expression Language 编写自定义表达式。直接用名称引用状态(<code>query</code>),而不是 <code>{{braced}}</code>。",
"learnMore": "了解更多",
@@ -2015,6 +2024,8 @@
"caseNamePlaceholder": "分支名称(可选)",
"variablePlaceholder": "变量",
"valuePlaceholder": "值",
"operator": "运算符",
"examplePlaceholder": "例如 {{example}}",
"opContains": "包含",
"opStartsWith": "开头为",
"conditionPlaceholder": "输入条件,例如 query.contains(\"refund\")",
@@ -2026,7 +2037,11 @@
"cannotDeleteStart": "无法删除开始节点",
"deleteNode": "删除节点",
"deleteConfirm": "确定要删除“{{name}}”吗?",
"deleteConfirmUnnamed": "确定要删除此工作流智能体吗?"
"deleteConfirmUnnamed": "确定要删除此工作流智能体吗?",
"detailsTitle": "工作流详情",
"detailsDescription": "显示在代理卡片上的名称、描述和图片。",
"unsavedChanges": "未保存的更改",
"editDetailsMenu": "编辑详情"
},
"validation": {
"nameRequired": "工作流名称为必填项",
@@ -2119,7 +2134,10 @@
"searchPlaceholder": "搜索变量...",
"empty": "未找到变量",
"addContext": "添加上下文"
}
},
"zoomIn": "放大",
"zoomOut": "缩小",
"fitView": "适应视图"
},
"typeModal": {
"title": "创建新智能体",