feat(pipeline): optional AI prompt when creating a pipeline script

This commit is contained in:
Ruben Fiszel
2026-05-10 14:16:05 +00:00
parent f9de54799a
commit 99ab97215c
5 changed files with 110 additions and 16 deletions
@@ -31,7 +31,8 @@
source:
| { kind: 'schedule'; cron: string }
| { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind
outputKind: PipelineOutputKind,
aiPrompt?: string
) => void
pathPrefix: string
defaultPathSuffix: string
@@ -49,7 +50,8 @@
pick.language as ScriptLang,
pick.path,
{ kind: 'schedule', cron: data.defaultScheduleCron },
outputKind
outputKind,
pick.aiPrompt
)
} else {
// Native trigger reference: user is expected to fill in the
@@ -60,7 +62,8 @@
pick.language as ScriptLang,
pick.path,
{ kind: kindId, path: undefined },
outputKind
outputKind,
pick.aiPrompt
)
}
}
@@ -36,11 +36,15 @@
asset: { kind: AssetKind; path: string },
language: import('$lib/gen').ScriptLang,
scriptPath: string,
outputKind: import('./pipelineTemplates').PipelineOutputKind
outputKind: import('./pipelineTemplates').PipelineOutputKind,
aiPrompt?: string
) => void
// Pipeline-wide + node shown at the top of the graph. Picking any
// kind from the menu invokes this one callback with the chosen
// trigger source — the page uses it to seed the draft's annotation.
// `aiPrompt` is set when the user filled the optional prompt field
// on the path stage; the page is expected to bootstrap the body via
// AI instead of using the seeded template.
onAddPipelineScript?: (
language: import('$lib/gen').ScriptLang,
path: string,
@@ -50,7 +54,8 @@
kind: 'webhook' | 'email' | 'kafka' | 'mqtt' | 'nats' | 'postgres' | 'sqs' | 'gcp'
path: string | undefined
},
outputKind: import('./pipelineTemplates').PipelineOutputKind
outputKind: import('./pipelineTemplates').PipelineOutputKind,
aiPrompt?: string
) => void
// Folder-scoped prefix shown as a read-only chip in the insert menu
// path input (e.g. `f/{folder}/`). Shared across top + and per-asset +.
@@ -30,7 +30,8 @@
asset: { kind: AssetKind; path: string },
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind
outputKind: PipelineOutputKind,
aiPrompt?: string
) => void
pathPrefix?: string
defaultPathSuffix?: string
@@ -102,7 +103,8 @@
{ kind: data.asset_kind, path: data.path },
pick.language as ScriptLang,
pick.path,
(pick.outputKind ?? 'none') as PipelineOutputKind
(pick.outputKind ?? 'none') as PipelineOutputKind,
pick.aiPrompt
)
}
}
@@ -21,6 +21,11 @@
// Picked output asset kind. Optional because some menu instances
// (those without `pickOutputKind`) skip that stage entirely.
outputKind?: string
// Optional natural-language prompt entered on the path stage.
// When set, the caller is expected to bootstrap the script body
// from this prompt via AI (using language + outputKind + the
// upstream input as context) instead of using the seeded template.
aiPrompt?: string
}
</script>
@@ -28,7 +33,7 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import { ArrowLeft, ChevronRight } from 'lucide-svelte'
import { ArrowLeft, ChevronRight, Sparkles } from 'lucide-svelte'
import { tick } from 'svelte'
import {
PIPELINE_OUTPUT_KINDS,
@@ -91,6 +96,11 @@
let selectedOutputKind = $state<PipelineOutputKind | undefined>(undefined)
let pathSuffix = $state('')
let pathInput: HTMLInputElement | undefined = $state(undefined)
// Optional AI prompt — when filled the caller bootstraps the script
// body via AI instead of the seeded template (see the PipelineInsertPick
// docstring above). Cleared on menu close along with the rest of the
// stage state so a stale prompt from a previous open doesn't leak.
let aiPrompt = $state('')
// Keyboard navigation state. `focusCol` tracks which column the arrow
// keys steer; per-column indexes survive column switches so the user's
@@ -128,6 +138,7 @@
selectedLanguage = languages[defaultLangIdx()]?.lang
selectedOutputKind = undefined
pathSuffix = ''
aiPrompt = ''
focusCol = 'lang'
kindIdx = 0
langIdx = defaultLangIdx()
@@ -302,11 +313,13 @@
const suffix = pathSuffix.trim()
if (!suffix || !selectedLanguage) return
if (pickOutputKind && !selectedOutputKind) return
const trimmedPrompt = aiPrompt.trim()
onPick({
kindId: selectedKindId,
language: selectedLanguage,
path: pathPrefix + suffix,
outputKind: pickOutputKind ? selectedOutputKind : undefined
outputKind: pickOutputKind ? selectedOutputKind : undefined,
aiPrompt: trimmedPrompt.length > 0 ? trimmedPrompt : undefined
})
close()
}
@@ -522,14 +535,38 @@
</div>
<span class="text-2xs text-tertiary">Press Enter to create</span>
</div>
<div class="flex flex-col gap-1">
<span class="flex items-center gap-1 text-2xs font-normal text-secondary">
<Sparkles size={11} />
AI prompt (optional)
</span>
<textarea
bind:value={aiPrompt}
onkeydown={(e) => {
// Cmd/Ctrl-Enter to submit even when focus is in
// the textarea — bare Enter inserts a newline
// (multi-line prompts are common).
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
confirmPath(close)
}
}}
class="border rounded-md bg-surface px-2 py-1.5 text-2xs font-normal min-h-[60px] resize-y focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400"
placeholder="Describe what the script should do — leave empty to use the seeded template"
></textarea>
<span class="text-2xs text-tertiary">
Filled in: AI generates the body using language + selected output as context.
</span>
</div>
<div class="flex justify-end mt-auto">
<Button
variant="accent"
unifiedSize="sm"
disabled={!pathSuffix.trim()}
onClick={() => confirmPath(close)}
startIcon={aiPrompt.trim() ? { icon: Sparkles } : undefined}
>
Create
{aiPrompt.trim() ? 'Generate' : 'Create'}
</Button>
</div>
</div>
@@ -23,7 +23,8 @@
type DraftTriggerSource
} from '$lib/components/assets/AssetGraph/pipelineTemplates'
import { decodeState, encodeState } from '$lib/utils'
import { onMount, untrack } from 'svelte'
import { onMount, tick, untrack } from 'svelte'
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import {
AlertTriangle,
ArrowLeft,
@@ -314,7 +315,8 @@
scriptPath: string,
triggers: DraftTriggerSource[],
outputKind: PipelineOutputKind,
input?: { kind: AssetKind; path: string }
input?: { kind: AssetKind; path: string },
aiPrompt?: string
) {
const out = autoOutputAsset(outputKind, folder, language)
const script = buildDraft(language, scriptPath, triggers, outputKind, out, input)
@@ -327,6 +329,43 @@
drafts = next
activeDraftPath = scriptPath
selection = undefined
// User filled the optional prompt on the path stage — fire off a
// chat request so the AI bootstraps the body. The seeded template
// (already in `script.content`) acts as scaffolding the AI
// rewrites; language + chosen output + upstream input go in as
// context so the model knows what to read from / write to.
if (aiPrompt && aiPrompt.trim().length > 0) {
void triggerAiBootstrap({ scriptPath, language, outputKind, input, out, prompt: aiPrompt })
}
}
async function triggerAiBootstrap(args: {
scriptPath: string
language: ScriptLang
outputKind: PipelineOutputKind
input?: { kind: AssetKind; path: string }
out?: { kind: AssetKind; path: string }
prompt: string
}) {
// Wait one tick for AssetGraphDetailsPane to mount the ScriptEditor
// and register itself with aiChatManager (scriptEditorApplyCode etc.).
// Without this, openChat fires before the chat has a target editor.
await tick()
const lines: string[] = [args.prompt]
lines.push('')
lines.push(`Language: ${args.language}.`)
if (args.input) {
lines.push(`Read input from ${args.input.kind} \`${args.input.path}\`.`)
}
if (args.out) {
lines.push(`Write output to ${args.out.kind} \`${args.out.path}\`.`)
} else if (args.outputKind === 'none') {
lines.push('No output asset is expected.')
}
const instructions = lines.join('\n')
aiChatManager.openChat()
aiChatManager.sendRequest({ instructions })
}
// Navigation guard state. `pendingNavigationUrl` holds the URL the user
@@ -1152,7 +1191,7 @@
selection = s
}
}}
onAddScriptForAsset={(asset, language, scriptPath, outputKind) => {
onAddScriptForAsset={(asset, language, scriptPath, outputKind, aiPrompt) => {
const ref = `${ASSET_PREFIX[asset.kind]}${asset.path}`
openMaterializerDraft(
language,
@@ -1162,11 +1201,19 @@
{
kind: asset.kind,
path: asset.path
}
},
aiPrompt
)
}}
onAddPipelineScript={(language, scriptPath, source, outputKind) =>
openMaterializerDraft(language, scriptPath, [source], outputKind)}
onAddPipelineScript={(language, scriptPath, source, outputKind, aiPrompt) =>
openMaterializerDraft(
language,
scriptPath,
[source],
outputKind,
undefined,
aiPrompt
)}
onRunnableMenuRemove={(info) => {
// Drafts: drop the local entry immediately — the
// existing onDiscard pathway already handles this