mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
feat: let flow AI chat create and edit sticky notes (#9412)
* feat: let flow AI chat create and edit sticky notes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: strengthen flow AI guidance to prefer groups for organizing flows Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: harden flow note validation (validate position/size, document color default and group acceptance) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: make AI-created free notes draggable by seeding default position and size Free notes need explicit geometry to be draggable/resizable in the editor; UI-created notes always set position+size but agent-created notes omitted both, so they couldn't be moved until resized. Seed defaults in validateFlowNotes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -196,14 +196,15 @@
|
||||
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
|
||||
},
|
||||
|
||||
setFlowJson: async ({ modules, schema, preprocessorModule, failureModule, groups }) => {
|
||||
setFlowJson: async ({ modules, schema, preprocessorModule, failureModule, groups, notes }) => {
|
||||
try {
|
||||
if (
|
||||
modules !== undefined ||
|
||||
schema !== undefined ||
|
||||
preprocessorModule !== undefined ||
|
||||
failureModule !== undefined ||
|
||||
groups !== undefined
|
||||
groups !== undefined ||
|
||||
notes !== undefined
|
||||
) {
|
||||
// Take snapshot of current flowStore and set as beforeFlow
|
||||
if (!diffManager?.hasPendingChanges) {
|
||||
@@ -218,7 +219,8 @@
|
||||
schema,
|
||||
preprocessorModule,
|
||||
failureModule,
|
||||
groups
|
||||
groups,
|
||||
notes
|
||||
})
|
||||
|
||||
// Refresh the state store to update UI
|
||||
|
||||
@@ -32,7 +32,13 @@ import type { ContextElement } from '../context'
|
||||
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree'
|
||||
import { createInlineScriptSession, type InlineScriptSession } from './inlineScriptsUtils'
|
||||
import { validateFlowGroups, type FlowGroup, type FlowJsonUpdateResult } from './helperUtils'
|
||||
import {
|
||||
validateFlowGroups,
|
||||
validateFlowNotes,
|
||||
type FlowGroup,
|
||||
type FlowNote,
|
||||
type FlowJsonUpdateResult
|
||||
} from './helperUtils'
|
||||
import { flowModuleSchema } from './openFlowZod.gen'
|
||||
import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree'
|
||||
import {
|
||||
@@ -50,6 +56,7 @@ type FlowJsonUpdate = {
|
||||
preprocessorModule?: FlowModule | null
|
||||
failureModule?: FlowModule | null
|
||||
groups?: FlowGroup[] | null
|
||||
notes?: FlowNote[] | null
|
||||
}
|
||||
|
||||
function formatEmptyInlineScriptWarning({
|
||||
@@ -179,6 +186,13 @@ const setFlowJsonToolSchema = z.object({
|
||||
.nullable()
|
||||
.describe(
|
||||
'JSON string containing the optional array of semantic flow groups. Each group has summary, note, autocollapse, start_id, end_id, color. color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes or other strings. Pass null to clear groups.'
|
||||
),
|
||||
notes: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.describe(
|
||||
'JSON string containing the optional array of free-floating sticky notes attached to the flow. Use notes to surface important flow-wide information (what the flow does, key assumptions, warnings, TODOs). Each note has id (unique string), text (markdown), color (one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes), and optional position {x,y} and size {width,height} — omit both and the editor places and sizes the note automatically. Always use type "free". The "group" note type is DEPRECATED — to segment a complex flow into labelled, colored sections use the `groups` field instead (each group carries its own note and color). Pass null to clear notes.'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -548,7 +562,8 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
schema: parsedFlow.schema,
|
||||
preprocessorModule: parsedFlow.preprocessor_module,
|
||||
failureModule: parsedFlow.failure_module,
|
||||
groups: parsedFlow.groups
|
||||
groups: parsedFlow.groups,
|
||||
notes: parsedFlow.notes
|
||||
})
|
||||
const warning = formatEmptyInlineScriptWarning(updateResult)
|
||||
|
||||
@@ -651,13 +666,14 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
showDetails: true,
|
||||
showFade: true,
|
||||
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
||||
const { modules, schema, preprocessor_module, failure_module, groups } = args
|
||||
const { modules, schema, preprocessor_module, failure_module, groups, notes } = args
|
||||
|
||||
let parsedModules: FlowModule[] | null | undefined
|
||||
let parsedSchema: Record<string, any> | null | undefined
|
||||
let parsedPreprocessorModule: FlowModule | null | undefined
|
||||
let parsedFailureModule: FlowModule | null | undefined
|
||||
let parsedGroups: FlowGroup[] | null | undefined
|
||||
let parsedNotes: FlowNote[] | null | undefined
|
||||
|
||||
// Parse JSON strings
|
||||
parsedModules = parseOptionalJsonArg(modules, 'modules') as FlowModule[] | null | undefined
|
||||
@@ -674,6 +690,7 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
| null
|
||||
| undefined
|
||||
parsedGroups = parseOptionalJsonArg(groups, 'groups') as FlowGroup[] | null | undefined
|
||||
parsedNotes = parseOptionalJsonArg(notes, 'notes') as FlowNote[] | null | undefined
|
||||
if (parsedModules === null) {
|
||||
parsedModules = undefined
|
||||
}
|
||||
@@ -702,11 +719,16 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
)
|
||||
parsedFailureModule = validateSpecialFlowModule(parsedFailureModule, 'failure_module')
|
||||
|
||||
if (parsedGroups !== undefined) {
|
||||
if (parsedGroups !== undefined || parsedNotes !== undefined) {
|
||||
const effectiveModules =
|
||||
parsedModules ?? helpers.getFlowAndSelectedId().flow.value.modules ?? []
|
||||
const moduleIdsForGroups = new Set(collectAllFlowModuleIdsFromModules(effectiveModules))
|
||||
parsedGroups = validateFlowGroups(parsedGroups, moduleIdsForGroups)
|
||||
if (parsedGroups !== undefined) {
|
||||
parsedGroups = validateFlowGroups(parsedGroups, moduleIdsForGroups)
|
||||
}
|
||||
if (parsedNotes !== undefined) {
|
||||
parsedNotes = validateFlowNotes(parsedNotes, moduleIdsForGroups)
|
||||
}
|
||||
}
|
||||
|
||||
const ids = [
|
||||
@@ -729,7 +751,8 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
? { preprocessorModule: parsedPreprocessorModule }
|
||||
: {}),
|
||||
...(parsedFailureModule !== undefined ? { failureModule: parsedFailureModule } : {}),
|
||||
...(parsedGroups !== undefined ? { groups: parsedGroups } : {})
|
||||
...(parsedGroups !== undefined ? { groups: parsedGroups } : {}),
|
||||
...(parsedNotes !== undefined ? { notes: parsedNotes } : {})
|
||||
})
|
||||
const warning = formatEmptyInlineScriptWarning(updateResult)
|
||||
|
||||
@@ -831,7 +854,7 @@ export function prepareFlowSystemMessage(customPrompt?: string): ChatCompletionS
|
||||
Use \`patch_flow_json\` for small, localized changes when you can target an exact snippet from the \`CURRENT FLOW JSON COMPACT\` block below.
|
||||
|
||||
Always copy the exact search text from the \`CURRENT FLOW JSON COMPACT\` block below.
|
||||
The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\` keys.
|
||||
The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\` keys.
|
||||
|
||||
**Parameters:**
|
||||
- \`old_string\`: Exact JSON text to find
|
||||
@@ -859,7 +882,16 @@ Use the \`set_flow_json\` tool to set the entire flow structure at once. Provide
|
||||
- \`schema\`: Flow input schema in JSON Schema format (optional)
|
||||
- \`preprocessor_module\`: Special module that runs before \`modules\` (optional, separate from \`modules\`)
|
||||
- \`failure_module\`: Special module that runs on failure (optional, separate from \`modules\`)
|
||||
- \`groups\`: Array of semantic groups for organizing modules in the editor (optional). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups.
|
||||
- \`groups\`: Array of semantic groups for organizing modules in the editor (optional, but **strongly recommended** — proactively segment any non-trivial flow into groups so it reads clearly; don't wait to be asked). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups.
|
||||
- \`notes\`: Array of free-floating sticky notes shown in the editor (optional). Each note has \`id\` (unique string), \`text\` (markdown content), \`color\` (same palette as groups: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — never hex codes), and optional \`position\` {x, y} / \`size\` {width, height} (omit both — the editor auto-places and sizes the note). Always set \`type\` to \`free\`. The \`group\` note type is **deprecated** — do not create group notes; use the \`groups\` field to segment a flow instead. Notes are documentation only and do not affect execution. Pass \`null\` to clear existing notes.
|
||||
|
||||
### When to use notes vs groups
|
||||
|
||||
**Strongly prefer \`groups\` to organize flows.** Groups are the primary way to make a flow readable: whenever a flow has more than a couple of steps, or any time consecutive steps form a logical stage (e.g. "fetch", "transform", "notify"), segment them into \`groups\`. Each group spans a range of steps (\`start_id\`..\`end_id\`), carries its own \`summary\`, \`note\` (markdown under the group header), and \`color\`, and can be collapsed. Proactively add or update groups when building or restructuring a flow — do not wait to be asked. Aim for every meaningful step to belong to a semantic group.
|
||||
|
||||
- **\`groups\` (default, use liberally):** segment a flow into labelled semantic sections. This is the main organizational tool — reach for it on essentially any non-trivial flow, not just "complex" ones.
|
||||
- **\`notes\` (free sticky notes, use sparingly):** reserve for important flow-wide information that does not belong to a specific span of steps — overall purpose, key assumptions, warnings, or TODOs. Usually a single note is enough; do not use notes to label sequences of steps (that is what \`groups\` are for).
|
||||
- Do **not** use \`group\`-type notes (deprecated) — \`groups\` is the supported way to group steps.
|
||||
|
||||
**Example - Simple flow:**
|
||||
\`\`\`javascript
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FlowModule, FlowValue } from '$lib/gen'
|
||||
import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree'
|
||||
import { SPECIAL_MODULE_IDS } from '../shared'
|
||||
import type { InlineScriptSession } from './inlineScriptsUtils'
|
||||
import { validateFlowGroups, type FlowGroup } from './helperUtils'
|
||||
import { validateFlowGroups, validateFlowNotes, type FlowGroup, type FlowNote } from './helperUtils'
|
||||
import { flowModuleSchema, flowModulesSchema } from './openFlowZod.gen'
|
||||
|
||||
/**
|
||||
@@ -19,6 +19,7 @@ export type EditableFlowJson = {
|
||||
preprocessor_module: FlowModule | null
|
||||
failure_module: FlowModule | null
|
||||
groups: FlowGroup[] | null
|
||||
notes: FlowNote[] | null
|
||||
}
|
||||
|
||||
/** Optional input to the rich-error path of `validateEditableFlowJson`. */
|
||||
@@ -145,8 +146,10 @@ function formatJsonSchemaForError(jsonSchema: any): string {
|
||||
if (jsonSchema.enum) {
|
||||
return `one of: ${jsonSchema.enum.map((v: any) => JSON.stringify(v)).join(', ')}`
|
||||
}
|
||||
if (jsonSchema.oneOf) return jsonSchema.oneOf.map((s: any) => formatJsonSchemaForError(s)).join(' | ')
|
||||
if (jsonSchema.anyOf) return jsonSchema.anyOf.map((s: any) => formatJsonSchemaForError(s)).join(' | ')
|
||||
if (jsonSchema.oneOf)
|
||||
return jsonSchema.oneOf.map((s: any) => formatJsonSchemaForError(s)).join(' | ')
|
||||
if (jsonSchema.anyOf)
|
||||
return jsonSchema.anyOf.map((s: any) => formatJsonSchemaForError(s)).join(' | ')
|
||||
if (jsonSchema.description) return jsonSchema.description
|
||||
return jsonSchema.type || JSON.stringify(jsonSchema)
|
||||
}
|
||||
@@ -177,7 +180,10 @@ function getExpectedFormat(schema: z.ZodType): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateFlowModules(rawModules: unknown, ctx: SchemaErrorContext = {}): FlowModule[] {
|
||||
export function validateFlowModules(
|
||||
rawModules: unknown,
|
||||
ctx: SchemaErrorContext = {}
|
||||
): FlowModule[] {
|
||||
if (!Array.isArray(rawModules)) {
|
||||
throw new Error('Flow modules must be an array')
|
||||
}
|
||||
@@ -275,6 +281,7 @@ export function validateEditableFlowJson(
|
||||
const failureModule = validateOptionalFlowModule(flow.failure_module, 'failure_module')
|
||||
const groupModuleIds = new Set(collectAllFlowModuleIdsFromModules(modules))
|
||||
const groups = validateFlowGroups(flow.groups, groupModuleIds)
|
||||
const notes = validateFlowNotes(flow.notes, groupModuleIds)
|
||||
|
||||
if (preprocessorModule) {
|
||||
if (preprocessorModule.id !== SPECIAL_MODULE_IDS.PREPROCESSOR) {
|
||||
@@ -316,7 +323,8 @@ export function validateEditableFlowJson(
|
||||
schema,
|
||||
preprocessor_module: preprocessorModule,
|
||||
failure_module: failureModule,
|
||||
groups
|
||||
groups,
|
||||
notes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,7 +379,8 @@ export function buildEditableFlowJson(
|
||||
schema: flow.schema ?? null,
|
||||
preprocessor_module: preprocessorModule ?? null,
|
||||
failure_module: failureModule ?? null,
|
||||
groups: flow.value.groups ?? null
|
||||
groups: flow.value.groups ?? null,
|
||||
notes: flow.value.notes ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +416,7 @@ export function applyEditableFlowJsonToFlow(
|
||||
preprocessor_module:
|
||||
restoreSpecialRawscriptModule(editable.preprocessor_module, session) ?? undefined,
|
||||
failure_module: restoreSpecialRawscriptModule(editable.failure_module, session) ?? undefined,
|
||||
groups: editable.groups ?? undefined
|
||||
groups: editable.groups ?? undefined,
|
||||
notes: editable.notes ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { FlowModule } from '$lib/gen'
|
||||
import {
|
||||
applyFlowJsonUpdate,
|
||||
updateRawScriptModuleContent,
|
||||
validateFlowGroups
|
||||
validateFlowGroups,
|
||||
validateFlowNotes
|
||||
} from './helperUtils'
|
||||
import { createInlineScriptSession } from './inlineScriptsUtils'
|
||||
|
||||
@@ -222,6 +223,70 @@ describe('applyFlowJsonUpdate', () => {
|
||||
expect((flow.value as any).groups).toEqual(existingGroups)
|
||||
})
|
||||
|
||||
it('persists notes passed in the flow json update', () => {
|
||||
const flow = {
|
||||
value: {
|
||||
modules: [makeRawScriptModule('fetch_data', 'existing code')]
|
||||
}
|
||||
}
|
||||
const inlineScriptSession = createInlineScriptSession()
|
||||
inlineScriptSession.set('fetch_data', 'existing code')
|
||||
|
||||
applyFlowJsonUpdate(flow as any, inlineScriptSession, {
|
||||
notes: [
|
||||
{
|
||||
id: 'note_1',
|
||||
text: 'Remember to set the API key',
|
||||
color: 'yellow',
|
||||
type: 'free'
|
||||
} as any
|
||||
]
|
||||
})
|
||||
|
||||
expect((flow.value as any).notes).toEqual([
|
||||
{
|
||||
id: 'note_1',
|
||||
text: 'Remember to set the API key',
|
||||
color: 'yellow',
|
||||
type: 'free'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('clears notes when an empty array or null is passed', () => {
|
||||
const flow = {
|
||||
value: {
|
||||
modules: [],
|
||||
notes: [{ id: 'n', text: 't', color: 'yellow', type: 'free' }]
|
||||
}
|
||||
}
|
||||
const inlineScriptSession = createInlineScriptSession()
|
||||
|
||||
applyFlowJsonUpdate(flow as any, inlineScriptSession, { notes: [] })
|
||||
expect((flow.value as any).notes).toBeUndefined()
|
||||
;(flow.value as any).notes = [{ id: 'n', text: 't', color: 'yellow', type: 'free' }]
|
||||
applyFlowJsonUpdate(flow as any, inlineScriptSession, { notes: null })
|
||||
expect((flow.value as any).notes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves notes untouched when not provided in the update', () => {
|
||||
const existingNotes = [{ id: 'n', text: 't', color: 'yellow', type: 'free' }]
|
||||
const flow = {
|
||||
value: {
|
||||
modules: [makeRawScriptModule('a', 'existing code')],
|
||||
notes: existingNotes
|
||||
}
|
||||
}
|
||||
const inlineScriptSession = createInlineScriptSession()
|
||||
inlineScriptSession.set('a', 'existing code')
|
||||
|
||||
applyFlowJsonUpdate(flow as any, inlineScriptSession, {
|
||||
modules: [makeRawScriptModule('a', 'inline_script.a')]
|
||||
})
|
||||
|
||||
expect((flow.value as any).notes).toEqual(existingNotes)
|
||||
})
|
||||
|
||||
it('updates ai agent rawscript tools in place when changing module code', () => {
|
||||
const flow = {
|
||||
value: {
|
||||
@@ -319,3 +384,162 @@ describe('validateFlowGroups', () => {
|
||||
expect(result).toEqual([{ start_id: 'a', end_id: 'b' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFlowNotes', () => {
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(validateFlowNotes(null)).toBeNull()
|
||||
expect(validateFlowNotes(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-array input', () => {
|
||||
expect(() => validateFlowNotes({})).toThrow('Flow notes must be an array')
|
||||
})
|
||||
|
||||
it('rejects a note that is not an object', () => {
|
||||
expect(() => validateFlowNotes(['nope'])).toThrow('Invalid note at index 0: must be an object')
|
||||
})
|
||||
|
||||
it('rejects a note with a missing or non-string id', () => {
|
||||
expect(() => validateFlowNotes([{ text: 't' }])).toThrow(
|
||||
'Invalid note at index 0: id must be a non-empty string'
|
||||
)
|
||||
expect(() => validateFlowNotes([{ id: '', text: 't' }])).toThrow(
|
||||
'Invalid note at index 0: id must be a non-empty string'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects duplicate note ids', () => {
|
||||
expect(() =>
|
||||
validateFlowNotes([
|
||||
{ id: 'n', text: 'a' },
|
||||
{ id: 'n', text: 'b' }
|
||||
])
|
||||
).toThrow('Invalid note at index 1: duplicate note id "n"')
|
||||
})
|
||||
|
||||
it('rejects a non-string text', () => {
|
||||
expect(() => validateFlowNotes([{ id: 'n', text: 42 }])).toThrow(
|
||||
'Invalid note at index 0: text must be a string'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an invalid type', () => {
|
||||
expect(() => validateFlowNotes([{ id: 'n', text: 't', type: 'sticky' }])).toThrow(
|
||||
'Invalid note at index 0: type must be "free" or "group"'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unknown color name', () => {
|
||||
expect(() => validateFlowNotes([{ id: 'n', text: 't', color: '#ffff00' }])).toThrow(
|
||||
/color must be one of/
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults type to free and color to the default note color', () => {
|
||||
const [note] = validateFlowNotes([{ id: 'n', text: 't' }])!
|
||||
expect(note).toMatchObject({ id: 'n', text: 't', type: 'free', color: 'green' })
|
||||
})
|
||||
|
||||
it('preserves a provided palette color rather than overriding it', () => {
|
||||
const [note] = validateFlowNotes([{ id: 'n', text: 't', color: 'purple' }])!
|
||||
expect(note).toMatchObject({ id: 'n', text: 't', type: 'free', color: 'purple' })
|
||||
})
|
||||
|
||||
it('gives a free note missing geometry a concrete position and size', () => {
|
||||
const [note] = validateFlowNotes([{ id: 'n', text: 't' }])!
|
||||
expect(note.position).toEqual({ x: expect.any(Number), y: expect.any(Number) })
|
||||
expect(note.size).toEqual({ width: expect.any(Number), height: expect.any(Number) })
|
||||
expect(note.size!.width).toBeGreaterThan(0)
|
||||
expect(note.size!.height).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('staggers the default y position of multiple geometry-less free notes', () => {
|
||||
const notes = validateFlowNotes([
|
||||
{ id: 'a', text: 't' },
|
||||
{ id: 'b', text: 't' }
|
||||
])!
|
||||
expect(notes[0].position!.y).not.toEqual(notes[1].position!.y)
|
||||
})
|
||||
|
||||
it('does not override a free note that already has geometry', () => {
|
||||
const [note] = validateFlowNotes([
|
||||
{ id: 'n', text: 't', position: { x: 5, y: 6 }, size: { width: 400, height: 90 } }
|
||||
])!
|
||||
expect(note.position).toEqual({ x: 5, y: 6 })
|
||||
expect(note.size).toEqual({ width: 400, height: 90 })
|
||||
})
|
||||
|
||||
it('does not add geometry to group notes', () => {
|
||||
const [note] = validateFlowNotes([
|
||||
{ id: 'n', text: 't', type: 'group', contained_node_ids: [] }
|
||||
])!
|
||||
expect(note.position).toBeUndefined()
|
||||
expect(note.size).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a malformed position', () => {
|
||||
expect(() => validateFlowNotes([{ id: 'n', text: 't', position: { x: 1 } }])).toThrow(
|
||||
'Invalid note at index 0: position must be an object with numeric x and y'
|
||||
)
|
||||
expect(() => validateFlowNotes([{ id: 'n', text: 't', position: [1, 2] }])).toThrow(
|
||||
'Invalid note at index 0: position must be an object with numeric x and y'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a malformed size', () => {
|
||||
expect(() => validateFlowNotes([{ id: 'n', text: 't', size: { width: '10' } }])).toThrow(
|
||||
'Invalid note at index 0: size must be an object with numeric width and height'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a free note with valid position and size', () => {
|
||||
const result = validateFlowNotes([
|
||||
{
|
||||
id: 'n',
|
||||
text: 't',
|
||||
color: 'blue',
|
||||
position: { x: 10, y: 20 },
|
||||
size: { width: 300, height: 80 }
|
||||
}
|
||||
])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'n',
|
||||
text: 't',
|
||||
type: 'free',
|
||||
color: 'blue',
|
||||
position: { x: 10, y: 20 },
|
||||
size: { width: 300, height: 80 }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects group note contained_node_ids that are not strings', () => {
|
||||
expect(() =>
|
||||
validateFlowNotes([{ id: 'n', text: 't', type: 'group', contained_node_ids: [1] }])
|
||||
).toThrow('Invalid note at index 0: contained_node_ids must be an array of strings')
|
||||
})
|
||||
|
||||
it('rejects group note contained_node_ids that do not match a module', () => {
|
||||
const moduleIds = new Set(['a', 'b'])
|
||||
expect(() =>
|
||||
validateFlowNotes(
|
||||
[{ id: 'n', text: 't', type: 'group', contained_node_ids: ['missing'] }],
|
||||
moduleIds
|
||||
)
|
||||
).toThrow(
|
||||
'Invalid note at index 0: contained_node_ids "missing" does not match any flow module'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a valid group note whose contained ids are all modules', () => {
|
||||
const moduleIds = new Set(['a', 'b'])
|
||||
const result = validateFlowNotes(
|
||||
[{ id: 'n', text: 't', color: 'blue', type: 'group', contained_node_ids: ['a', 'b'] }],
|
||||
moduleIds
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ id: 'n', text: 't', color: 'blue', type: 'group', contained_node_ids: ['a', 'b'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { FlowModule, FlowValue, OpenFlow, RawScript } from '$lib/gen'
|
||||
import type { FlowModule, FlowNote, FlowValue, OpenFlow, RawScript } from '$lib/gen'
|
||||
import { forEachFlowModule } from '$lib/components/flows/dfs'
|
||||
import { findModuleInFlow } from '$lib/components/flows/flowTree'
|
||||
import { NoteColor } from '$lib/components/graph/noteColors'
|
||||
import {
|
||||
DEFAULT_NOTE_COLOR,
|
||||
MIN_NOTE_HEIGHT,
|
||||
MIN_NOTE_WIDTH,
|
||||
NoteColor
|
||||
} from '$lib/components/graph/noteColors'
|
||||
import type { InlineScriptSession } from './inlineScriptsUtils'
|
||||
|
||||
/** Allowed group color names — matches the NoteColor palette the group
|
||||
* editor uses. Other strings would render with default-blue styling at best
|
||||
* and break the color picker UI at worst. */
|
||||
const ALLOWED_GROUP_COLORS = new Set<string>(Object.values(NoteColor))
|
||||
/** Allowed note/group color names — matches the NoteColor palette the note and
|
||||
* group editors use. The note renderer keys `NOTE_COLORS` by these exact names,
|
||||
* so other strings (hex codes, CSS colors) render with no styling at best and
|
||||
* break the color picker UI at worst. */
|
||||
const ALLOWED_NOTE_COLORS = new Set<string>(Object.values(NoteColor))
|
||||
|
||||
type FlowLike = Pick<OpenFlow, 'value'> & {
|
||||
schema?: Record<string, any>
|
||||
}
|
||||
|
||||
export type FlowGroup = NonNullable<FlowValue['groups']>[number]
|
||||
export type { FlowNote }
|
||||
|
||||
export interface FlowJsonUpdate {
|
||||
modules?: FlowModule[]
|
||||
@@ -21,6 +28,7 @@ export interface FlowJsonUpdate {
|
||||
preprocessorModule?: FlowModule | null
|
||||
failureModule?: FlowModule | null
|
||||
groups?: FlowGroup[] | null
|
||||
notes?: FlowNote[] | null
|
||||
}
|
||||
|
||||
export interface FlowJsonUpdateResult {
|
||||
@@ -78,9 +86,9 @@ export function validateFlowGroups(
|
||||
}
|
||||
}
|
||||
if (g.color !== undefined && g.color !== null) {
|
||||
if (typeof g.color !== 'string' || !ALLOWED_GROUP_COLORS.has(g.color)) {
|
||||
if (typeof g.color !== 'string' || !ALLOWED_NOTE_COLORS.has(g.color)) {
|
||||
throw new Error(
|
||||
`Invalid group at index ${index}: color must be one of ${[...ALLOWED_GROUP_COLORS].join(', ')}`
|
||||
`Invalid group at index ${index}: color must be one of ${[...ALLOWED_NOTE_COLORS].join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,10 +96,138 @@ export function validateFlowGroups(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the optional array of sticky notes the agent attached to the flow.
|
||||
* Notes are editor-only annotations and do not affect execution.
|
||||
*
|
||||
* `free` notes are the supported kind (standalone canvas annotations). The
|
||||
* `group` note type is deprecated for creation — the chat prompt steers the
|
||||
* agent toward `groups` instead — but it is still ACCEPTED here so flows that
|
||||
* already contain group notes round-trip cleanly through `patch_flow_json` /
|
||||
* `set_flow_json` rather than being rejected. When `moduleIds` is provided,
|
||||
* every `contained_node_ids` entry of a `group` note must reference an existing
|
||||
* module.
|
||||
*
|
||||
* A provided palette `color` is always preserved as-is; the default is only
|
||||
* filled in when a note omits `color` entirely (FlowNote.color is required).
|
||||
*
|
||||
* Free notes are also given a concrete `position` and `size` when missing. A
|
||||
* free note without geometry is not draggable/resizable in the editor (you'd
|
||||
* have to resize it first to give it a size) — UI-created notes always set both,
|
||||
* so agent-created notes must too. Provided geometry is preserved untouched.
|
||||
*/
|
||||
export function validateFlowNotes(rawNotes: unknown, moduleIds?: Set<string>): FlowNote[] | null {
|
||||
if (rawNotes == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!Array.isArray(rawNotes)) {
|
||||
throw new Error('Flow notes must be an array')
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
return rawNotes.map((note, index) => {
|
||||
if (!note || typeof note !== 'object' || Array.isArray(note)) {
|
||||
throw new Error(`Invalid note at index ${index}: must be an object`)
|
||||
}
|
||||
const n = note as Record<string, unknown>
|
||||
if (typeof n.id !== 'string' || !n.id) {
|
||||
throw new Error(`Invalid note at index ${index}: id must be a non-empty string`)
|
||||
}
|
||||
if (seenIds.has(n.id)) {
|
||||
throw new Error(`Invalid note at index ${index}: duplicate note id "${n.id}"`)
|
||||
}
|
||||
seenIds.add(n.id)
|
||||
if (typeof n.text !== 'string') {
|
||||
throw new Error(`Invalid note at index ${index}: text must be a string`)
|
||||
}
|
||||
const type = n.type ?? 'free'
|
||||
if (type !== 'free' && type !== 'group') {
|
||||
throw new Error(`Invalid note at index ${index}: type must be "free" or "group"`)
|
||||
}
|
||||
if (n.color !== undefined && n.color !== null) {
|
||||
if (typeof n.color !== 'string' || !ALLOWED_NOTE_COLORS.has(n.color)) {
|
||||
throw new Error(
|
||||
`Invalid note at index ${index}: color must be one of ${[...ALLOWED_NOTE_COLORS].join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (n.position !== undefined && n.position !== null) {
|
||||
const p = n.position as Record<string, unknown>
|
||||
if (
|
||||
typeof p !== 'object' ||
|
||||
Array.isArray(n.position) ||
|
||||
typeof p.x !== 'number' ||
|
||||
typeof p.y !== 'number'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid note at index ${index}: position must be an object with numeric x and y`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (n.size !== undefined && n.size !== null) {
|
||||
const s = n.size as Record<string, unknown>
|
||||
if (
|
||||
typeof s !== 'object' ||
|
||||
Array.isArray(n.size) ||
|
||||
typeof s.width !== 'number' ||
|
||||
typeof s.height !== 'number'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid note at index ${index}: size must be an object with numeric width and height`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (type === 'group' && n.contained_node_ids !== undefined) {
|
||||
if (
|
||||
!Array.isArray(n.contained_node_ids) ||
|
||||
n.contained_node_ids.some((id) => typeof id !== 'string')
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid note at index ${index}: contained_node_ids must be an array of strings`
|
||||
)
|
||||
}
|
||||
if (moduleIds) {
|
||||
for (const id of n.contained_node_ids as string[]) {
|
||||
if (!moduleIds.has(id)) {
|
||||
throw new Error(
|
||||
`Invalid note at index ${index}: contained_node_ids "${id}" does not match any flow module`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const normalized = {
|
||||
...(n as FlowNote),
|
||||
type,
|
||||
// Preserve a provided color; only seed the default when omitted.
|
||||
color: typeof n.color === 'string' ? n.color : DEFAULT_NOTE_COLOR
|
||||
} as FlowNote
|
||||
|
||||
// Free notes need explicit geometry to be draggable/resizable. Place
|
||||
// missing ones to the left of the flow column, staggered by index so
|
||||
// several new notes don't land exactly on top of each other. Group notes
|
||||
// derive their layout from contained nodes, so they are left alone.
|
||||
if (type === 'free') {
|
||||
if (normalized.position == null) {
|
||||
normalized.position = {
|
||||
x: -(MIN_NOTE_WIDTH + 100),
|
||||
y: index * (MIN_NOTE_HEIGHT + 24)
|
||||
}
|
||||
}
|
||||
if (normalized.size == null) {
|
||||
normalized.size = { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT }
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
|
||||
export function applyFlowJsonUpdate(
|
||||
flow: FlowLike,
|
||||
inlineScriptSession: InlineScriptSession,
|
||||
{ modules, schema, preprocessorModule, failureModule, groups }: FlowJsonUpdate
|
||||
{ modules, schema, preprocessorModule, failureModule, groups, notes }: FlowJsonUpdate
|
||||
): FlowJsonUpdateResult {
|
||||
const emptyInlineScriptModuleIds = new Set<string>()
|
||||
|
||||
@@ -125,6 +261,10 @@ export function applyFlowJsonUpdate(
|
||||
flow.value.groups = groups == null || groups.length === 0 ? undefined : groups
|
||||
}
|
||||
|
||||
if (notes !== undefined) {
|
||||
flow.value.notes = notes == null || notes.length === 0 ? undefined : notes
|
||||
}
|
||||
|
||||
return {
|
||||
emptyInlineScriptModuleIds: Array.from(emptyInlineScriptModuleIds)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user