Offer settings from other nodes, help with cron, follow the ports
Suggest a parameter's value from the nodes of the same type in every flow, the way message names are already offered; secrets stay out of it. Explain the cron fields, and derive the expression an interval asks for. Keep an untouched function scaffold in step with the node's ports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Maximize2, Minimize2, X } from "lucide-react"
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
type ComponentProps,
|
||||
lazy,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import {
|
||||
type DType,
|
||||
@@ -37,6 +44,7 @@ import {
|
||||
libraryQueryOptions,
|
||||
nodeSourceQueryOptions,
|
||||
secretsQueryOptions,
|
||||
useParamSuggestions,
|
||||
} from "./queries"
|
||||
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
|
||||
|
||||
@@ -52,6 +60,93 @@ const SECTION = PANEL_SECTION
|
||||
/** Settings the engine reads itself, so they are not the author's to name. */
|
||||
const RESERVED_PARAMS = new Set(["synchronous"])
|
||||
|
||||
/**
|
||||
* A text field that offers what is already in use elsewhere.
|
||||
*
|
||||
* Typing stays free — the list is a shortcut, never a constraint — but picking
|
||||
* beats typing wherever two things only work together when they match.
|
||||
*/
|
||||
function SuggestInput({
|
||||
value,
|
||||
suggestions,
|
||||
empty,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
}: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
/** What to say when nothing matches what is typed. */
|
||||
empty: string
|
||||
onChange: (next: string) => void
|
||||
} & Omit<ComponentProps<typeof Input>, "value" | "onChange">) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const matches = suggestions.filter(
|
||||
(item) =>
|
||||
item !== value && item.toLowerCase().includes(value.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
value={value}
|
||||
autoComplete="off"
|
||||
{...props}
|
||||
onFocus={(event) => {
|
||||
setOpen(true)
|
||||
onFocus?.(event)
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setOpen(false)
|
||||
onBlur?.(event)
|
||||
}}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
<CommandEmpty>{empty}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{matches.map((item) => (
|
||||
<CommandItem
|
||||
key={item}
|
||||
value={item}
|
||||
className="font-mono text-sm"
|
||||
// Blur fires before click, so commit on mousedown.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onChange(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
onSelect={() => {
|
||||
onChange(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A message name, typed freely or picked from the names already in play.
|
||||
*
|
||||
@@ -74,77 +169,29 @@ function MessageNameInput({
|
||||
/** The name as it was before this edit, once the field is done with. */
|
||||
onRenamed?: (previous: string, next: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
// Every keystroke commits, so a rename is only a rename once the user is
|
||||
// finished with the field.
|
||||
const before = useRef(value)
|
||||
const matches = suggestions.filter(
|
||||
(name) =>
|
||||
name !== value && name.toLowerCase().includes(value.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
aria-label="Message name"
|
||||
autoComplete="off"
|
||||
// A port added by hand is meant to be named right away.
|
||||
autoFocus={autoFocus}
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onFocus={() => {
|
||||
before.current = value
|
||||
setOpen(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOpen(false)
|
||||
if (before.current !== value) onRenamed?.(before.current, value)
|
||||
before.current = value
|
||||
}}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
<CommandEmpty>No matching message.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{matches.map((name) => (
|
||||
<CommandItem
|
||||
key={name}
|
||||
value={name}
|
||||
className="font-mono text-sm"
|
||||
// Blur fires before click, so commit on mousedown.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onChange(name)
|
||||
setOpen(false)
|
||||
}}
|
||||
onSelect={() => {
|
||||
onChange(name)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<SuggestInput
|
||||
value={value}
|
||||
suggestions={suggestions}
|
||||
empty="No matching message."
|
||||
placeholder={placeholder}
|
||||
aria-label="Message name"
|
||||
// A port added by hand is meant to be named right away.
|
||||
autoFocus={autoFocus}
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onChange={onChange}
|
||||
onFocus={() => {
|
||||
before.current = value
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (before.current !== value) onRenamed?.(before.current, value)
|
||||
before.current = value
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -423,18 +470,75 @@ function FreeParamsForm({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The cron expression that fires as often as `seconds` asks for, if there is
|
||||
* one. Only divisors of an hour or a day line up — a step of seven minutes
|
||||
* would jump from :56 back to :00, which is not every seven minutes.
|
||||
*/
|
||||
function cronFromInterval(seconds: unknown): string | null {
|
||||
const value = Number(seconds)
|
||||
if (!Number.isFinite(value) || value <= 0) return null
|
||||
const minutes = value / 60
|
||||
// Cron's finest grain is the minute.
|
||||
if (!Number.isInteger(minutes)) return null
|
||||
if (minutes === 1) return "* * * * *"
|
||||
if (minutes < 60) return 60 % minutes === 0 ? `*/${minutes} * * * *` : null
|
||||
const hours = minutes / 60
|
||||
if (!Number.isInteger(hours)) return null
|
||||
if (hours === 1) return "0 * * * *"
|
||||
if (hours < 24) return 24 % hours === 0 ? `0 */${hours} * * *` : null
|
||||
return hours === 24 ? "0 0 * * *" : null
|
||||
}
|
||||
|
||||
/** What the five fields mean, and the schedule the interval beside them asks for. */
|
||||
function CronHelp({
|
||||
params,
|
||||
onPick,
|
||||
}: {
|
||||
params: Record<string, unknown>
|
||||
onPick: (expression: string) => void
|
||||
}) {
|
||||
const derived = cronFromInterval(params.interval)
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Five fields —{" "}
|
||||
<span className="font-mono">
|
||||
minute hour day-of-month month weekday
|
||||
</span>
|
||||
. <span className="font-mono">*</span> is every,{" "}
|
||||
<span className="font-mono">*/5</span> every fifth,{" "}
|
||||
<span className="font-mono">1-5</span> a range.
|
||||
</p>
|
||||
{derived && params.cron !== derived ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 justify-self-start text-xs text-muted-foreground"
|
||||
data-testid="use-derived-cron"
|
||||
onClick={() => onPick(derived)}
|
||||
>
|
||||
Every {String(params.interval)}s is
|
||||
<span className="font-mono">{derived}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small form built from the node type's declared parameters. */
|
||||
function ParamsForm({
|
||||
type,
|
||||
schema,
|
||||
params,
|
||||
onChange,
|
||||
}: {
|
||||
type: string | undefined
|
||||
schema: Record<string, unknown> | undefined
|
||||
params: Record<string, unknown>
|
||||
onChange: (next: Record<string, unknown>) => void
|
||||
}) {
|
||||
const { data: secretList } = useQuery(secretsQueryOptions())
|
||||
const secrets = secretList?.data ?? []
|
||||
const properties = (schema?.properties ?? {}) as Record<
|
||||
string,
|
||||
{
|
||||
@@ -446,6 +550,12 @@ function ParamsForm({
|
||||
}
|
||||
>
|
||||
const entries = Object.entries(properties)
|
||||
|
||||
const { data: secretList } = useQuery(secretsQueryOptions())
|
||||
const secrets = secretList?.data ?? []
|
||||
// A type with nothing to fill in has nothing to suggest, and the other flows
|
||||
// are not worth reading for it.
|
||||
const suggestions = useParamSuggestions(entries.length ? type : undefined)
|
||||
if (entries.length === 0) return null
|
||||
|
||||
const set = (key: string, value: unknown) =>
|
||||
@@ -519,23 +629,26 @@ function ParamsForm({
|
||||
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
<SuggestInput
|
||||
id={`param-${key}`}
|
||||
className="h-8 text-sm"
|
||||
type={numeric ? "number" : "text"}
|
||||
value={String(value)}
|
||||
onChange={(event) =>
|
||||
set(
|
||||
key,
|
||||
numeric ? Number(event.target.value) : event.target.value,
|
||||
)
|
||||
}
|
||||
suggestions={suggestions[key] ?? []}
|
||||
empty="Nothing like that in use yet."
|
||||
onChange={(next) => set(key, numeric ? Number(next) : next)}
|
||||
/>
|
||||
{property.description ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{property.description}
|
||||
</p>
|
||||
) : null}
|
||||
{key === "cron" ? (
|
||||
<CronHelp
|
||||
params={params}
|
||||
onPick={(expression) => set(key, expression)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -645,6 +758,57 @@ function SharingSection({
|
||||
)
|
||||
}
|
||||
|
||||
/** The value a fresh output returns, so the scaffold runs as written. */
|
||||
const PLACEHOLDER: Record<DType, string> = {
|
||||
float: "0.0",
|
||||
int: "0",
|
||||
bool: "False",
|
||||
str: '""',
|
||||
json: "{}",
|
||||
}
|
||||
|
||||
const SCAFFOLD_DOC =
|
||||
'"""A new node. Return a dict keyed by your output ports."""'
|
||||
|
||||
const quote = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
/**
|
||||
* Exactly what `scaffoldFor` writes, for any set of ports — and the source a
|
||||
* node starts life with, which is that with no ports at all.
|
||||
*
|
||||
* Nothing else matches, which is what makes it safe to overwrite: a returned
|
||||
* value that is not one of the placeholders is somebody's code.
|
||||
*/
|
||||
const SCAFFOLD_SHAPE = (() => {
|
||||
const value = Object.values(PLACEHOLDER).map(quote).join("|")
|
||||
const entry = `"[^"]+": (?:${value})`
|
||||
return new RegExp(
|
||||
`^${quote(SCAFFOLD_DOC)}\\n\\n\\ndef process\\(\\w+(?:, \\w+)*\\):\\n return \\{(?:${entry}(?:, ${entry})*)?\\}\\n$`,
|
||||
)
|
||||
})()
|
||||
|
||||
/** The name the node function sees; the engine derives it the same way. */
|
||||
const portName = (spec: MessageSpec) =>
|
||||
spec.port || (spec.name ?? "").split(".").pop() || ""
|
||||
|
||||
/** A `process` that takes this node's inputs and returns its outputs. */
|
||||
function scaffoldFor(node: NodeDef_Input): string {
|
||||
const args = [
|
||||
...new Set(
|
||||
(node.requires ?? [])
|
||||
.map(portName)
|
||||
// Anything else cannot be a keyword argument, so it cannot be a port.
|
||||
.filter((port) => /^[A-Za-z_]\w*$/.test(port) && port !== "params"),
|
||||
),
|
||||
]
|
||||
const returns = (node.provides ?? [])
|
||||
.filter((spec) => portName(spec))
|
||||
.map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`)
|
||||
return `${SCAFFOLD_DOC}\n\n\ndef process(${[...args, "params"].join(
|
||||
", ",
|
||||
)}):\n return {${returns.join(", ")}}\n`
|
||||
}
|
||||
|
||||
function PanelBody({
|
||||
node,
|
||||
flow,
|
||||
@@ -699,6 +863,21 @@ function PanelBody({
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the node, and keep an untouched scaffold in step with its ports.
|
||||
*
|
||||
* Only code that is still exactly what this panel generates is rewritten —
|
||||
* one edit of your own and it is yours, ports or no ports. Shared code is
|
||||
* never rewritten either: the other flows using it have their own ports.
|
||||
*/
|
||||
const editNode = (next: NodeDef_Input) => {
|
||||
onChange(next)
|
||||
const current = code ?? source?.code
|
||||
if (!hasSource || next.source_ref || current === undefined) return
|
||||
const wanted = scaffoldFor(next)
|
||||
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
|
||||
}
|
||||
|
||||
// ⌘S in the editor means "apply this code"; the editor's own state is here,
|
||||
// so the binding is too. Anywhere else on the canvas it publishes the flow.
|
||||
useShortcuts(
|
||||
@@ -729,7 +908,7 @@ function PanelBody({
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node reads."
|
||||
suggestions={suggestions.consumes}
|
||||
onChange={(requires) => onChange({ ...node, requires })}
|
||||
onChange={(requires) => editNode({ ...node, requires })}
|
||||
/>
|
||||
<PortList
|
||||
title="Provides"
|
||||
@@ -737,12 +916,13 @@ function PanelBody({
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
suggestions={suggestions.provides}
|
||||
onChange={(provides) => onChange({ ...node, provides })}
|
||||
onChange={(provides) => editNode({ ...node, provides })}
|
||||
// Only the publishing side names a message; an input is as often
|
||||
// re-pointed at a different one as it is renamed.
|
||||
onRenamed={onRenameMessage}
|
||||
/>
|
||||
<ParamsForm
|
||||
type={node.type}
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
onChange={(params) => onChange({ ...node, params })}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
type UseMutationResult,
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
@@ -62,6 +64,51 @@ export const messageHistoryQueryOptions = (name: string, message: string) => ({
|
||||
queryFn: () => FlowsService.readMessageHistory({ name, message }),
|
||||
})
|
||||
|
||||
/** Other flows change rarely; suggestions do not need them fresh to the second. */
|
||||
const SUGGEST_STALE = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Values already in use by nodes of the same type, keyed by parameter.
|
||||
*
|
||||
* The second MQTT node points at the same broker as the first one, so the
|
||||
* settings are worth offering rather than making someone type them again.
|
||||
*
|
||||
* Only plain values are collected: a secret is stored as a reference object
|
||||
* and stays out of this, which is the point of the secret picker.
|
||||
*/
|
||||
export function useParamSuggestions(
|
||||
type: string | undefined,
|
||||
): Record<string, string[]> {
|
||||
const { data: flows } = useQuery({
|
||||
...flowsQueryOptions(),
|
||||
staleTime: SUGGEST_STALE,
|
||||
})
|
||||
// ponytail: reads every flow to collect them, sharing the editor's own cache
|
||||
// entries; an aggregate endpoint if a big installation makes that hurt.
|
||||
const details = useQueries({
|
||||
queries: (type ? (flows?.data ?? []) : []).map((flow) => ({
|
||||
...flowQueryOptions(flow.name),
|
||||
staleTime: SUGGEST_STALE,
|
||||
})),
|
||||
})
|
||||
|
||||
const seen: Record<string, Set<string>> = {}
|
||||
for (const { data } of details) {
|
||||
for (const node of data?.definition.nodes ?? []) {
|
||||
if (node.type !== type) continue
|
||||
for (const [key, value] of Object.entries(node.params ?? {})) {
|
||||
if (value === null || value === "" || typeof value === "object")
|
||||
continue
|
||||
if (!seen[key]) seen[key] = new Set()
|
||||
seen[key].add(String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(seen).map(([key, values]) => [key, [...values].sort()]),
|
||||
)
|
||||
}
|
||||
|
||||
const AUTOSAVE_DELAY = 800
|
||||
/** How long to wait for a save in flight before sending the next one. */
|
||||
const RETRY_DELAY = 100
|
||||
|
||||
Reference in New Issue
Block a user