mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 14:14:50 +00:00
Workflow revamp
This commit is contained in:
1 parent
d4c97a49ce
commit
4070b03053
43 files changed
+3559
-2010
No files matched your search
+87
-11
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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=">">></SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
<SelectItem value=">=">>=</SelectItem>
|
||||
<SelectItem value="<="><=</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 };
|
||||
}
|
||||
@@ -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+/);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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="fixed": 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Создать нового агента",
|
||||
|
||||
@@ -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
@@ -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": "创建新智能体",
|
||||
|
||||
Reference in new issue
Block a user