create_agent, bind_channel, setup_identity all modify openclaw.json and need a gateway restart. Previously the hint only showed when config_patch steps ran. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
7.4 KiB
TypeScript
217 lines
7.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { api } from "../lib/api";
|
|
import { ParamForm } from "../components/ParamForm";
|
|
import { resolveSteps, executeStep, type ResolvedStep } from "../lib/actions";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import type { DiscordGuildChannel, Recipe } from "../lib/types";
|
|
|
|
type Phase = "params" | "confirm" | "execute" | "done";
|
|
type StepStatus = "pending" | "running" | "done" | "failed" | "skipped";
|
|
|
|
export function Cook({
|
|
recipeId,
|
|
onDone,
|
|
recipeSource,
|
|
discordGuildChannels,
|
|
}: {
|
|
recipeId: string;
|
|
onDone?: () => void;
|
|
recipeSource?: string;
|
|
discordGuildChannels: DiscordGuildChannel[];
|
|
}) {
|
|
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
|
const [params, setParams] = useState<Record<string, string>>({});
|
|
const [phase, setPhase] = useState<Phase>("params");
|
|
const [resolvedStepList, setResolvedStepList] = useState<ResolvedStep[]>([]);
|
|
const [stepStatuses, setStepStatuses] = useState<StepStatus[]>([]);
|
|
const [stepErrors, setStepErrors] = useState<Record<number, string>>({});
|
|
const [needsRestart, setNeedsRestart] = useState(false);
|
|
|
|
useEffect(() => {
|
|
api.listRecipes(recipeSource).then((recipes) => {
|
|
const found = recipes.find((it) => it.id === recipeId);
|
|
setRecipe(found || null);
|
|
if (found) {
|
|
const defaults: Record<string, string> = {};
|
|
for (const p of found.params) {
|
|
defaults[p.id] = p.defaultValue ?? (p.type === "boolean" ? "false" : "");
|
|
}
|
|
setParams(defaults);
|
|
}
|
|
});
|
|
}, [recipeId, recipeSource]);
|
|
|
|
if (!recipe) return <div>Recipe not found</div>;
|
|
|
|
const handleNext = () => {
|
|
const steps = resolveSteps(recipe.steps, params);
|
|
setResolvedStepList(steps);
|
|
// Auto-skip steps whose template args resolved to empty
|
|
setStepStatuses(steps.map((s) => (s.skippable ? "skipped" : "pending")));
|
|
setStepErrors({});
|
|
setNeedsRestart(steps.some((s) => !s.skippable));
|
|
setPhase("confirm");
|
|
};
|
|
|
|
const runFrom = async (startIndex: number, statuses: StepStatus[]) => {
|
|
for (let i = startIndex; i < resolvedStepList.length; i++) {
|
|
if (statuses[i] === "skipped") continue;
|
|
statuses[i] = "running";
|
|
setStepStatuses([...statuses]);
|
|
try {
|
|
await executeStep(resolvedStepList[i]);
|
|
statuses[i] = "done";
|
|
} catch (err) {
|
|
statuses[i] = "failed";
|
|
setStepErrors((prev) => ({ ...prev, [i]: String(err) }));
|
|
setStepStatuses([...statuses]);
|
|
return;
|
|
}
|
|
setStepStatuses([...statuses]);
|
|
}
|
|
setPhase("done");
|
|
};
|
|
|
|
const handleExecute = () => {
|
|
setPhase("execute");
|
|
const statuses = [...stepStatuses];
|
|
runFrom(0, statuses);
|
|
};
|
|
|
|
const handleRetry = (index: number) => {
|
|
const statuses = [...stepStatuses];
|
|
setStepErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[index];
|
|
return next;
|
|
});
|
|
runFrom(index, statuses);
|
|
};
|
|
|
|
const handleSkip = (index: number) => {
|
|
const statuses = [...stepStatuses];
|
|
statuses[index] = "skipped";
|
|
setStepStatuses(statuses);
|
|
setStepErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[index];
|
|
return next;
|
|
});
|
|
const nextIndex = statuses.findIndex((s, i) => i > index && s !== "skipped");
|
|
if (nextIndex === -1) {
|
|
setPhase("done");
|
|
} else {
|
|
runFrom(nextIndex, statuses);
|
|
}
|
|
};
|
|
|
|
const statusIcon = (s: StepStatus) => {
|
|
switch (s) {
|
|
case "pending": return "\u25CB";
|
|
case "running": return "\u25C9";
|
|
case "done": return "\u2713";
|
|
case "failed": return "\u2717";
|
|
case "skipped": return "\u2013";
|
|
}
|
|
};
|
|
|
|
const statusColor = (s: StepStatus) => {
|
|
switch (s) {
|
|
case "done": return "text-green-600";
|
|
case "failed": return "text-destructive";
|
|
case "running": return "text-primary";
|
|
default: return "text-muted-foreground";
|
|
}
|
|
};
|
|
|
|
const doneCount = stepStatuses.filter((s) => s === "done").length;
|
|
const skippedCount = stepStatuses.filter((s) => s === "skipped").length;
|
|
|
|
return (
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Button variant="ghost" size="sm" className="px-2" onClick={onDone}>
|
|
←
|
|
</Button>
|
|
<h2 className="text-2xl font-bold">{recipe.name}</h2>
|
|
</div>
|
|
|
|
{phase === "params" && (
|
|
<ParamForm
|
|
recipe={recipe}
|
|
values={params}
|
|
onChange={(id, value) => setParams((prev) => ({ ...prev, [id]: value }))}
|
|
onSubmit={handleNext}
|
|
submitLabel="Next"
|
|
discordGuildChannels={discordGuildChannels}
|
|
/>
|
|
)}
|
|
|
|
{(phase === "confirm" || phase === "execute") && (
|
|
<Card>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{resolvedStepList.map((step, i) => (
|
|
<div key={i} className={cn("flex items-start gap-3", stepStatuses[i] === "skipped" && "opacity-50")}>
|
|
<span className={cn("text-lg font-mono w-5 text-center", statusColor(stepStatuses[i]))}>
|
|
{statusIcon(stepStatuses[i])}
|
|
</span>
|
|
<div className="flex-1">
|
|
<div className="text-sm font-medium">
|
|
{step.label}
|
|
{stepStatuses[i] === "skipped" && phase === "confirm" && (
|
|
<span className="text-xs text-muted-foreground ml-2">(skipped — empty params)</span>
|
|
)}
|
|
</div>
|
|
{step.description !== step.label && stepStatuses[i] !== "skipped" && (
|
|
<div className="text-xs text-muted-foreground">{step.description}</div>
|
|
)}
|
|
{stepErrors[i] && (
|
|
<div className="text-xs text-destructive mt-1">{stepErrors[i]}</div>
|
|
)}
|
|
{stepStatuses[i] === "failed" && (
|
|
<div className="flex gap-2 mt-1.5">
|
|
<Button size="sm" variant="outline" onClick={() => handleRetry(i)}>
|
|
Retry
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => handleSkip(i)}>
|
|
Skip
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{phase === "confirm" && (
|
|
<div className="flex gap-2 mt-4">
|
|
<Button onClick={handleExecute}>Execute</Button>
|
|
<Button variant="outline" onClick={() => setPhase("params")}>Back</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{phase === "done" && (
|
|
<Card>
|
|
<CardContent className="py-8 text-center">
|
|
<div className="text-2xl mb-2">✓</div>
|
|
<p className="text-lg font-medium">
|
|
{doneCount} step{doneCount !== 1 ? "s" : ""} completed
|
|
{skippedCount > 0 && `, ${skippedCount} skipped`}
|
|
</p>
|
|
{needsRestart && (
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Use "Apply Changes" in the sidebar to restart the gateway and activate config changes.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|