mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
feat(routines): add workspace-aware routine runs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
36376968af
commit
909e8cd4c8
38 changed files with 15468 additions and 250 deletions
232
ui/src/components/RoutineVariablesEditor.tsx
Normal file
232
ui/src/components/RoutineVariablesEditor.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const variableTypes: RoutineVariable["type"][] = ["text", "textarea", "number", "boolean", "select"];
|
||||
|
||||
function serializeVariables(value: RoutineVariable[]) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseSelectOptions(value: string) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function updateVariableList(
|
||||
variables: RoutineVariable[],
|
||||
name: string,
|
||||
mutate: (variable: RoutineVariable) => RoutineVariable,
|
||||
) {
|
||||
return variables.map((variable) => (variable.name === name ? mutate(variable) : variable));
|
||||
}
|
||||
|
||||
export function RoutineVariablesEditor({
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
description: string;
|
||||
value: RoutineVariable[];
|
||||
onChange: (value: RoutineVariable[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const syncedVariables = useMemo(
|
||||
() => syncRoutineVariablesWithTemplate(description, value),
|
||||
[description, value],
|
||||
);
|
||||
const syncedSignature = serializeVariables(syncedVariables);
|
||||
const currentSignature = serializeVariables(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncedSignature !== currentSignature) {
|
||||
onChange(syncedVariables);
|
||||
}
|
||||
}, [currentSignature, onChange, syncedSignature, syncedVariables]);
|
||||
|
||||
if (syncedVariables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border/70 px-3 py-2 text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Variables</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Detected from `{"{{name}}"}` placeholders in the routine instructions.
|
||||
</p>
|
||||
</div>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{syncedVariables.map((variable) => (
|
||||
<div key={variable.name} className="rounded-lg border border-border/70 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{`{{${variable.name}}}`}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Prompt the user for this value before each manual run.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
value={variable.label ?? ""}
|
||||
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
label: event.target.value || null,
|
||||
})))}
|
||||
placeholder={variable.name.replaceAll("_", " ")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={variable.type}
|
||||
onValueChange={(type) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
type: type as RoutineVariable["type"],
|
||||
defaultValue: type === "boolean" ? null : current.defaultValue,
|
||||
options: type === "select" ? current.options : [],
|
||||
})))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{variableTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-xs">Default value</Label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={variable.required}
|
||||
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
required: event.target.checked,
|
||||
})))}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{variable.type === "textarea" ? (
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={variable.defaultValue == null ? "" : String(variable.defaultValue)}
|
||||
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
defaultValue: event.target.value || null,
|
||||
})))}
|
||||
/>
|
||||
) : variable.type === "boolean" ? (
|
||||
<Select
|
||||
value={variable.defaultValue === true ? "true" : variable.defaultValue === false ? "false" : "__unset__"}
|
||||
onValueChange={(next) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
defaultValue: next === "__unset__" ? null : next === "true",
|
||||
})))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">No default</SelectItem>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : variable.type === "select" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Options</Label>
|
||||
<Input
|
||||
value={variable.options.join(", ")}
|
||||
onChange={(event) => {
|
||||
const options = parseSelectOptions(event.target.value);
|
||||
onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
options,
|
||||
defaultValue:
|
||||
typeof current.defaultValue === "string" && options.includes(current.defaultValue)
|
||||
? current.defaultValue
|
||||
: null,
|
||||
})));
|
||||
}}
|
||||
placeholder="high, medium, low"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Default option</Label>
|
||||
<Select
|
||||
value={typeof variable.defaultValue === "string" ? variable.defaultValue : "__unset__"}
|
||||
onValueChange={(next) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
defaultValue: next === "__unset__" ? null : next,
|
||||
})))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">No default</SelectItem>
|
||||
{variable.options.map((option) => (
|
||||
<SelectItem key={option} value={option}>{option}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={variable.type === "number" ? "number" : "text"}
|
||||
value={variable.defaultValue == null ? "" : String(variable.defaultValue)}
|
||||
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
defaultValue: event.target.value || null,
|
||||
})))}
|
||||
placeholder={variable.type === "number" ? "42" : "Default value"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutineVariablesHint() {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue