mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
feat: Custom events (#186)
* feat: Custom events * implementing initialization component synced with initialize node * handle irrelevant/skipped events * fix positioning, add collapse, rm comments * lint format * fix tsconfig, refactor DRY eventEmit, collapse initialize-step * auto collapse init-step after completion * revert yarn lock to main * formatting * cr * enable persisting init steps after stream * format * cr * cr * cr --------- Co-authored-by: starmorph <dylan@starmorph.com>
This commit is contained in:
parent
3d43abd4a0
commit
23418cab1c
17 changed files with 621 additions and 151 deletions
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { SYSTEM_PROMPT } from "./prompt.js";
|
||||
import { getRepoAbsolutePath } from "@open-swe/shared/git";
|
||||
import { getMissingMessages } from "../../../../utils/github/issue-messages.js";
|
||||
import { filterHiddenMessages } from "../../../../utils/message/filter-hidden.js";
|
||||
import { getTaskPlanFromIssue } from "../../../../utils/github/issue-task.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "GeneratePlanningMessageNode");
|
||||
|
|
@ -62,7 +63,7 @@ export async function generateAction(
|
|||
taskPlan: latestTaskPlan ?? state.taskPlan,
|
||||
}),
|
||||
},
|
||||
...state.messages,
|
||||
...filterHiddenMessages(state.messages),
|
||||
...missingMessages,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
isFollowupRequest,
|
||||
} from "../utils/followup.js";
|
||||
import { stopSandbox } from "../../../utils/sandbox.js";
|
||||
import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js";
|
||||
|
||||
const systemPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
{FOLLOWUP_MESSAGE_PROMPT}
|
||||
|
|
@ -80,7 +81,7 @@ export async function generatePlan(
|
|||
role: "system",
|
||||
content: formatSystemPrompt(state),
|
||||
},
|
||||
...state.messages,
|
||||
...filterHiddenMessages(state.messages),
|
||||
...(optionalToolMessage ? [optionalToolMessage] : []),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
getMessageContentFromIssue,
|
||||
getUntrackedComments,
|
||||
} from "../../../utils/github/issue-messages.js";
|
||||
import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js";
|
||||
|
||||
export async function prepareGraphState(
|
||||
state: PlannerGraphState,
|
||||
|
|
@ -92,8 +93,8 @@ export async function prepareGraphState(
|
|||
});
|
||||
}
|
||||
|
||||
// Remove all messages not marked as summaryMessage, and not human messages.
|
||||
const removedNonSummaryMessages = state.messages
|
||||
// Remove all messages not marked as summaryMessage, hidden, and not human messages.
|
||||
const removedNonSummaryMessages = filterHiddenMessages(state.messages)
|
||||
.filter((m) => !m.additional_kwargs?.summaryMessage && !isHumanMessage(m))
|
||||
.map((m: BaseMessage) => new RemoveMessage({ id: m.id ?? "" }));
|
||||
const summaryMessage = new AIMessage({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getRepoAbsolutePath } from "@open-swe/shared/git";
|
||||
import { getGitHubTokensFromConfig } from "../../utils/github-tokens.js";
|
||||
import { GraphConfig, TargetRepository } from "@open-swe/shared/open-swe/types";
|
||||
|
|
@ -10,7 +11,16 @@ import {
|
|||
pullLatestChanges,
|
||||
} from "../../utils/github/git.js";
|
||||
import { getCodebaseTree } from "../../utils/tree.js";
|
||||
import { SNAPSHOT_NAME } from "@open-swe/shared/constants";
|
||||
import {
|
||||
DO_NOT_RENDER_ID_PREFIX,
|
||||
SNAPSHOT_NAME,
|
||||
} from "@open-swe/shared/constants";
|
||||
import {
|
||||
CustomNodeEvent,
|
||||
INITIALIZE_NODE_ID,
|
||||
} from "@open-swe/shared/open-swe/custom-node-events";
|
||||
import { Sandbox } from "@daytonaio/sdk";
|
||||
import { AIMessage, BaseMessage } from "@langchain/core/messages";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "InitializeSandbox");
|
||||
|
||||
|
|
@ -19,6 +29,7 @@ type InitializeSandboxState = {
|
|||
branchName: string;
|
||||
sandboxSessionId?: string;
|
||||
codebaseTree?: string;
|
||||
messages?: BaseMessage[];
|
||||
};
|
||||
|
||||
export async function initializeSandbox(
|
||||
|
|
@ -28,65 +39,302 @@ export async function initializeSandbox(
|
|||
const { githubInstallationToken } = getGitHubTokensFromConfig(config);
|
||||
const { sandboxSessionId, targetRepository, branchName } = state;
|
||||
const absoluteRepoDir = getRepoAbsolutePath(targetRepository);
|
||||
const repoName = `${targetRepository.owner}/${targetRepository.repo}`;
|
||||
|
||||
const events: CustomNodeEvent[] = [];
|
||||
const emitStepEvent = (
|
||||
base: CustomNodeEvent,
|
||||
status: "pending" | "success" | "error" | "skipped",
|
||||
error?: string,
|
||||
) => {
|
||||
const event = {
|
||||
...base,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: {
|
||||
...base.data,
|
||||
status,
|
||||
...(error ? { error } : {}),
|
||||
},
|
||||
};
|
||||
events.push(event);
|
||||
try {
|
||||
config.writer?.(event);
|
||||
} catch (err) {
|
||||
logger.error("Failed to emit custom event", { event, err });
|
||||
}
|
||||
};
|
||||
const createEventsMessage = () => [
|
||||
new AIMessage({
|
||||
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
|
||||
content: "",
|
||||
additional_kwargs: {
|
||||
hidden: true,
|
||||
customNodeEvents: events,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
if (!sandboxSessionId) {
|
||||
emitStepEvent(
|
||||
{
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: uuidv4(),
|
||||
action: "Resuming Sandbox",
|
||||
data: {
|
||||
status: "skipped",
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
},
|
||||
"skipped",
|
||||
);
|
||||
emitStepEvent(
|
||||
{
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: uuidv4(),
|
||||
action: "Pulling latest changes",
|
||||
data: {
|
||||
status: "skipped",
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
},
|
||||
"skipped",
|
||||
);
|
||||
}
|
||||
|
||||
if (sandboxSessionId) {
|
||||
try {
|
||||
logger.info("Sandbox session ID exists. Resuming", {
|
||||
const resumeSandboxActionId = uuidv4();
|
||||
const baseResumeSandboxAction: CustomNodeEvent = {
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: resumeSandboxActionId,
|
||||
action: "Resuming Sandbox",
|
||||
data: {
|
||||
status: "pending",
|
||||
sandboxSessionId,
|
||||
});
|
||||
// Resume the sandbox if the session ID is in the config.
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
};
|
||||
emitStepEvent(baseResumeSandboxAction, "pending");
|
||||
|
||||
try {
|
||||
const existingSandbox = await daytonaClient().get(sandboxSessionId);
|
||||
await pullLatestChanges(absoluteRepoDir, existingSandbox);
|
||||
const codebaseTree = await getCodebaseTree(existingSandbox.id);
|
||||
return {
|
||||
sandboxSessionId: existingSandbox.id,
|
||||
codebaseTree,
|
||||
emitStepEvent(baseResumeSandboxAction, "success");
|
||||
|
||||
const pullLatestChangesActionId = uuidv4();
|
||||
const basePullLatestChangesAction: CustomNodeEvent = {
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: pullLatestChangesActionId,
|
||||
action: "Pulling latest changes",
|
||||
data: {
|
||||
status: "pending",
|
||||
sandboxSessionId,
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
// Error thrown, log it and continue. Will create a new sandbox session since the resumption failed.
|
||||
logger.error("Failed to get sandbox session", e);
|
||||
emitStepEvent(basePullLatestChangesAction, "pending");
|
||||
|
||||
const pullChangesRes = await pullLatestChanges(
|
||||
absoluteRepoDir,
|
||||
existingSandbox,
|
||||
);
|
||||
if (!pullChangesRes || pullChangesRes.exitCode !== 0) {
|
||||
emitStepEvent(
|
||||
basePullLatestChangesAction,
|
||||
"error",
|
||||
"Failed to pull latest changes. Please check your repository connection.",
|
||||
);
|
||||
throw new Error("Failed to pull latest changes.");
|
||||
}
|
||||
emitStepEvent(basePullLatestChangesAction, "success");
|
||||
|
||||
const generateCodebaseTreeActionId = uuidv4();
|
||||
const baseGenerateCodebaseTreeAction: CustomNodeEvent = {
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: generateCodebaseTreeActionId,
|
||||
action: "Generating codebase tree",
|
||||
data: {
|
||||
status: "pending",
|
||||
sandboxSessionId,
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
};
|
||||
emitStepEvent(baseGenerateCodebaseTreeAction, "pending");
|
||||
try {
|
||||
const codebaseTree = await getCodebaseTree(existingSandbox.id);
|
||||
emitStepEvent(baseGenerateCodebaseTreeAction, "success");
|
||||
return {
|
||||
sandboxSessionId: existingSandbox.id,
|
||||
codebaseTree,
|
||||
messages: createEventsMessage(),
|
||||
};
|
||||
} catch {
|
||||
emitStepEvent(
|
||||
baseGenerateCodebaseTreeAction,
|
||||
"error",
|
||||
"Failed to generate codebase tree. Please try again later.",
|
||||
);
|
||||
throw new Error("Failed to generate codebase tree.");
|
||||
}
|
||||
} catch {
|
||||
emitStepEvent(
|
||||
baseResumeSandboxAction,
|
||||
"error",
|
||||
"Failed to resume sandbox. A new environment will be created.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Creating sandbox...");
|
||||
const sandbox = await daytonaClient().create({
|
||||
image: SNAPSHOT_NAME,
|
||||
});
|
||||
// Creating Sandbox
|
||||
const createSandboxActionId = uuidv4();
|
||||
const baseCreateSandboxAction: CustomNodeEvent = {
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: createSandboxActionId,
|
||||
action: "Creating Sandbox",
|
||||
data: {
|
||||
status: "pending",
|
||||
sandboxSessionId: null,
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await cloneRepo(sandbox, targetRepository, {
|
||||
githubInstallationToken,
|
||||
stateBranchName: branchName,
|
||||
});
|
||||
if (res.exitCode !== 0) {
|
||||
throw new Error(`Failed to clone repository.\n${res.result}`);
|
||||
emitStepEvent(baseCreateSandboxAction, "pending");
|
||||
let sandbox: Sandbox;
|
||||
try {
|
||||
sandbox = await daytonaClient().create({ image: SNAPSHOT_NAME });
|
||||
emitStepEvent(baseCreateSandboxAction, "success");
|
||||
} catch {
|
||||
emitStepEvent(
|
||||
baseCreateSandboxAction,
|
||||
"error",
|
||||
"Failed to create sandbox environment. Please try again later.",
|
||||
);
|
||||
throw new Error("Failed to create sandbox environment.");
|
||||
}
|
||||
|
||||
logger.info("Repository cloned successfully.");
|
||||
// Cloning repository
|
||||
const cloneRepoActionId = uuidv4();
|
||||
const baseCloneRepoAction: CustomNodeEvent = {
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: cloneRepoActionId,
|
||||
action: "Cloning repository",
|
||||
data: {
|
||||
status: "pending",
|
||||
sandboxSessionId: sandbox.id,
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
};
|
||||
emitStepEvent(baseCloneRepoAction, "pending");
|
||||
const cloneRepoRes = await cloneRepo(sandbox, targetRepository, {
|
||||
githubInstallationToken,
|
||||
stateBranchName: state.branchName,
|
||||
});
|
||||
if (cloneRepoRes.exitCode !== 0) {
|
||||
emitStepEvent(
|
||||
baseCloneRepoAction,
|
||||
"error",
|
||||
"Failed to clone repository. Please check your repo URL and permissions.",
|
||||
);
|
||||
throw new Error("Failed to clone repository.");
|
||||
}
|
||||
emitStepEvent(baseCloneRepoAction, "success");
|
||||
|
||||
// Configuring git user
|
||||
const configureGitUserActionId = uuidv4();
|
||||
const baseConfigureGitUserAction: CustomNodeEvent = {
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: configureGitUserActionId,
|
||||
action: "Configuring git user",
|
||||
data: {
|
||||
status: "pending",
|
||||
sandboxSessionId: sandbox.id,
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
};
|
||||
emitStepEvent(baseConfigureGitUserAction, "pending");
|
||||
|
||||
logger.info(`Configuring git user for repository at "${absoluteRepoDir}"...`);
|
||||
await configureGitUserInRepo(absoluteRepoDir, sandbox, {
|
||||
githubInstallationToken,
|
||||
owner: targetRepository.owner,
|
||||
repo: targetRepository.repo,
|
||||
});
|
||||
logger.info("Git user configured successfully.");
|
||||
emitStepEvent(baseConfigureGitUserAction, "success");
|
||||
|
||||
// Checking out branch
|
||||
const checkoutBranchActionId = uuidv4();
|
||||
const baseCheckoutBranchAction: CustomNodeEvent = {
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: checkoutBranchActionId,
|
||||
action: "Checking out branch",
|
||||
data: {
|
||||
status: "pending",
|
||||
sandboxSessionId: sandbox.id,
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
};
|
||||
emitStepEvent(baseCheckoutBranchAction, "pending");
|
||||
const checkoutBranchRes = await checkoutBranch(
|
||||
absoluteRepoDir,
|
||||
branchName,
|
||||
sandbox,
|
||||
);
|
||||
|
||||
if (!checkoutBranchRes) {
|
||||
// TODO: This should probably be an interrupt.
|
||||
logger.error("Failed to checkout branch.");
|
||||
throw new Error("Failed to checkout branch");
|
||||
emitStepEvent(
|
||||
baseCheckoutBranchAction,
|
||||
"error",
|
||||
"Failed to checkout branch. Please check your branch name.",
|
||||
);
|
||||
throw new Error("Failed to checkout branch.");
|
||||
}
|
||||
emitStepEvent(baseCheckoutBranchAction, "success");
|
||||
|
||||
const codebaseTree = await getCodebaseTree(sandbox.id);
|
||||
// Generating codebase tree
|
||||
const generateCodebaseTreeActionId = uuidv4();
|
||||
const baseGenerateCodebaseTreeAction: CustomNodeEvent = {
|
||||
nodeId: INITIALIZE_NODE_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
actionId: generateCodebaseTreeActionId,
|
||||
action: "Generating codebase tree",
|
||||
data: {
|
||||
status: "pending",
|
||||
sandboxSessionId: sandbox.id,
|
||||
branch: branchName,
|
||||
repo: repoName,
|
||||
},
|
||||
};
|
||||
emitStepEvent(baseGenerateCodebaseTreeAction, "pending");
|
||||
let codebaseTree = undefined;
|
||||
try {
|
||||
codebaseTree = await getCodebaseTree(sandbox.id);
|
||||
emitStepEvent(baseGenerateCodebaseTreeAction, "success");
|
||||
} catch (_) {
|
||||
emitStepEvent(
|
||||
baseGenerateCodebaseTreeAction,
|
||||
"error",
|
||||
"Failed to generate codebase tree.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sandboxSessionId: sandbox.id,
|
||||
targetRepository,
|
||||
codebaseTree,
|
||||
messages: createEventsMessage(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ export function createLangGraphClient(options?: {
|
|||
defaultHeaders?: Record<string, string>;
|
||||
includeApiKey?: boolean;
|
||||
}) {
|
||||
// TODO: Remove the need for this after issues with port are resolved.
|
||||
const productionUrl = process.env.LANGGRAPH_PROD_URL;
|
||||
const port = process.env.PORT ?? "2024";
|
||||
if (options?.includeApiKey && !process.env.LANGGRAPH_API_KEY) {
|
||||
throw new Error("LANGGRAPH_API_KEY not found");
|
||||
|
|
@ -12,7 +14,7 @@ export function createLangGraphClient(options?: {
|
|||
...(options?.includeApiKey && {
|
||||
apiKey: process.env.LANGGRAPH_API_KEY,
|
||||
}),
|
||||
apiUrl: `http://localhost:${port}`,
|
||||
apiUrl: productionUrl ?? `http://localhost:${port}`,
|
||||
defaultHeaders: options?.defaultHeaders,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
5
apps/open-swe/src/utils/message/filter-hidden.ts
Normal file
5
apps/open-swe/src/utils/message/filter-hidden.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { BaseMessage } from "@langchain/core/messages";
|
||||
|
||||
export function filterHiddenMessages(messages: BaseMessage[]): BaseMessage[] {
|
||||
return messages.filter((message) => !message.additional_kwargs?.hidden);
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
"use client";
|
||||
|
||||
import "../app/globals.css";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
|
|
@ -8,36 +6,43 @@ import {
|
|||
GitBranch,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type Step = {
|
||||
name: string;
|
||||
status: "waiting" | "generating" | "success" | "error";
|
||||
error?: string;
|
||||
};
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Step } from "@open-swe/shared/open-swe/custom-node-events";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
type InitializeStepProps = {
|
||||
status: "loading" | "generating" | "done";
|
||||
success?: boolean;
|
||||
steps?: Step[];
|
||||
reasoningText?: string;
|
||||
summaryText?: string;
|
||||
collapse?: boolean;
|
||||
};
|
||||
|
||||
export function InitializeStep({
|
||||
status,
|
||||
success,
|
||||
steps,
|
||||
reasoningText,
|
||||
summaryText,
|
||||
collapse: collapseProp,
|
||||
}: InitializeStepProps) {
|
||||
const [showReasoning, setShowReasoning] = useState(false);
|
||||
const [showSummary, setShowSummary] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(collapseProp ?? false);
|
||||
const wasDone = useRef(false);
|
||||
|
||||
// Auto-collapse when status is 'done' and success is true
|
||||
useEffect(() => {
|
||||
if (status === "done" && success && !collapsed && !wasDone.current) {
|
||||
setCollapsed(true);
|
||||
wasDone.current = true;
|
||||
}
|
||||
if (status !== "done") {
|
||||
wasDone.current = false;
|
||||
}
|
||||
}, [status, success, collapsed]);
|
||||
|
||||
const stepStatusIcon = {
|
||||
waiting: (
|
||||
<div className="h-3.5 w-3.5 rounded-full border border-gray-300" />
|
||||
<div className={cn("h-3.5 w-3.5 rounded-full border border-gray-300")} />
|
||||
),
|
||||
generating: <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-500" />,
|
||||
success: <CheckCircle className="h-3.5 w-3.5 text-green-500" />,
|
||||
|
|
@ -48,7 +53,9 @@ export function InitializeStep({
|
|||
switch (status) {
|
||||
case "loading":
|
||||
return (
|
||||
<div className="h-3.5 w-3.5 rounded-full border border-gray-300" />
|
||||
<div
|
||||
className={cn("h-3.5 w-3.5 rounded-full border border-gray-300")}
|
||||
/>
|
||||
);
|
||||
case "generating":
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-500" />;
|
||||
|
|
@ -74,72 +81,69 @@ export function InitializeStep({
|
|||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
{reasoningText && (
|
||||
<div className="border-b border-blue-100 bg-blue-50 p-2">
|
||||
<button
|
||||
onClick={() => setShowReasoning(!showReasoning)}
|
||||
className="flex items-center gap-1 text-xs font-normal text-blue-700 hover:text-blue-800"
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{showReasoning ? "Hide reasoning" : "Show reasoning"}
|
||||
</button>
|
||||
{showReasoning && (
|
||||
<p className="mt-1 text-xs font-normal text-blue-800">
|
||||
{reasoningText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center border-b border-gray-200 bg-gray-50 p-2">
|
||||
{/* Collapse/Expand Icon */}
|
||||
<div className="relative flex items-center border-b border-gray-200 bg-gray-50 p-2">
|
||||
<GitBranch className="mr-2 h-3.5 w-3.5 text-gray-500" />
|
||||
<span className="flex-1 text-xs font-normal text-gray-800">
|
||||
{getStatusText()}
|
||||
</span>
|
||||
{getStatusIcon()}
|
||||
<Button
|
||||
aria-label={collapsed ? "Expand" : "Collapse"}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
collapsed ? "rotate-0" : "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{steps && (status === "generating" || status === "done") && (
|
||||
{/* Only render the rest if not collapsed */}
|
||||
{!collapsed && steps && steps.length > 0 && (
|
||||
<div className="p-2">
|
||||
<ul className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center text-xs"
|
||||
>
|
||||
<span className="mr-2">{stepStatusIcon[step.status]}</span>
|
||||
<span
|
||||
className={`font-normal ${step.status === "error" ? "text-red-500" : "text-gray-800"}`}
|
||||
{steps
|
||||
.filter((step) => step.status !== "skipped")
|
||||
.map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center text-xs"
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
{step.error && (
|
||||
<span className="ml-2 text-xs text-red-500">
|
||||
({step.error})
|
||||
<span className="mr-2">
|
||||
{stepStatusIcon[
|
||||
step.status as keyof typeof stepStatusIcon
|
||||
] ?? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 rounded-full border border-gray-300",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
"font-normal",
|
||||
step.status === "error"
|
||||
? "text-red-500"
|
||||
: "text-gray-800",
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
{step.error && (
|
||||
<span className="ml-2 text-xs text-red-500">
|
||||
({step.error})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summaryText && status === "done" && (
|
||||
<div className="border-t border-green-100 bg-green-50 p-2">
|
||||
<button
|
||||
onClick={() => setShowSummary(!showSummary)}
|
||||
className="flex items-center gap-1 text-xs font-normal text-green-700 hover:text-green-800"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
{showSummary ? "Hide summary" : "Show summary"}
|
||||
</button>
|
||||
{showSummary && (
|
||||
<p className="mt-1 text-xs font-normal text-green-800">
|
||||
{summaryText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export default function useInterruptedActions({
|
|||
config: {
|
||||
recursion_limit: 400,
|
||||
},
|
||||
streamResumable: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export function useProposedPlan(
|
|||
config: {
|
||||
recursion_limit: 400,
|
||||
},
|
||||
streamResumable: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -69,6 +70,7 @@ export function useProposedPlan(
|
|||
config: {
|
||||
recursion_limit: 400,
|
||||
},
|
||||
streamResumable: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ import { Button } from "../ui/button";
|
|||
import { Checkpoint, Message } from "@langchain/langgraph-sdk";
|
||||
import { AssistantMessage, AssistantMessageLoading } from "./messages/ai";
|
||||
import { HumanMessage } from "./messages/human";
|
||||
import {
|
||||
DO_NOT_RENDER_ID_PREFIX,
|
||||
ensureToolCallsHaveResponses,
|
||||
} from "@/lib/ensure-tool-responses";
|
||||
import { ensureToolCallsHaveResponses } from "@/lib/ensure-tool-responses";
|
||||
import { LangGraphLogoSVG } from "../icons/langgraph";
|
||||
import { TooltipIconButton } from "../ui/tooltip-icon-button";
|
||||
import {
|
||||
|
|
@ -52,6 +49,11 @@ import { TaskPlanView } from "../tasks";
|
|||
import { useTaskPlan } from "../tasks/useTaskPlan";
|
||||
import { isProposedPlanInterrupt } from "@/lib/plan-utils";
|
||||
import { HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||
import {
|
||||
INITIALIZE_NODE_ID,
|
||||
mapCustomEventsToSteps,
|
||||
} from "@open-swe/shared/open-swe/custom-node-events";
|
||||
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
|
||||
|
||||
function StickyToBottomContent(props: {
|
||||
content: ReactNode;
|
||||
|
|
@ -151,6 +153,7 @@ export function Thread() {
|
|||
const stream = useStreamContext();
|
||||
const messages = stream.messages;
|
||||
const isLoading = stream.isLoading;
|
||||
const customEvents = stream.customEvents;
|
||||
|
||||
const lastError = useRef<string | undefined>(undefined);
|
||||
|
||||
|
|
@ -321,6 +324,19 @@ export function Thread() {
|
|||
);
|
||||
const isLastMessageHuman = messages[messages.length - 1]?.type === "human";
|
||||
|
||||
const initializeEvents = customEvents.filter(
|
||||
(e) => e.nodeId === INITIALIZE_NODE_ID,
|
||||
);
|
||||
|
||||
const steps = mapCustomEventsToSteps(initializeEvents);
|
||||
const allSuccess =
|
||||
steps.length > 0 && steps.every((s) => s.status === "success");
|
||||
|
||||
let initStatus: "loading" | "generating" | "done" = "generating";
|
||||
if (allSuccess) {
|
||||
initStatus = "done";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
<div className="relative hidden lg:flex">
|
||||
|
|
@ -513,9 +529,11 @@ export function Thread() {
|
|||
thread={stream}
|
||||
/>
|
||||
)}
|
||||
{isLoading && !firstTokenReceived && (
|
||||
<AssistantMessageLoading />
|
||||
)}
|
||||
{isLoading &&
|
||||
!firstTokenReceived &&
|
||||
initializeEvents.length === 0 && (
|
||||
<AssistantMessageLoading />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { isHumanMessageSDK } from "@/lib/langchain-messages";
|
||||
import { isAIMessageSDK, isHumanMessageSDK } from "@/lib/langchain-messages";
|
||||
import { UseStream, useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { AssistantMessage } from "../thread/messages/ai";
|
||||
import { Dispatch, SetStateAction, useEffect, useRef } from "react";
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||
import {
|
||||
isCustomNodeEvent,
|
||||
CustomNodeEvent,
|
||||
INITIALIZE_NODE_ID,
|
||||
mapCustomEventsToSteps,
|
||||
} from "@open-swe/shared/open-swe/custom-node-events";
|
||||
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { InitializeStep } from "../gen-ui/initialize-step";
|
||||
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
||||
|
||||
|
|
@ -17,6 +26,28 @@ interface ActionsRendererProps {
|
|||
setSelectedTab?: Dispatch<SetStateAction<"planner" | "programmer">>;
|
||||
}
|
||||
|
||||
const getCustomNodeEventsFromMessages = (
|
||||
messages?: Message[],
|
||||
nodeId?: string,
|
||||
): CustomNodeEvent[] => {
|
||||
if (!messages) return [];
|
||||
|
||||
return messages
|
||||
.filter((m) => isAIMessageSDK(m))
|
||||
.filter((m) => {
|
||||
const events = m.additional_kwargs?.customNodeEvents as
|
||||
| CustomNodeEvent[]
|
||||
| undefined;
|
||||
if (!events?.length) return false;
|
||||
if (!nodeId) {
|
||||
return true;
|
||||
}
|
||||
return events.some((e) => e.nodeId === nodeId);
|
||||
})
|
||||
.map((m) => m.additional_kwargs?.customNodeEvents as CustomNodeEvent[])
|
||||
.flat();
|
||||
};
|
||||
|
||||
export function ActionsRenderer<State extends PlannerGraphState | GraphState>({
|
||||
graphId,
|
||||
threadId,
|
||||
|
|
@ -25,23 +56,60 @@ export function ActionsRenderer<State extends PlannerGraphState | GraphState>({
|
|||
programmerSession,
|
||||
setSelectedTab,
|
||||
}: ActionsRendererProps) {
|
||||
const [customNodeEvents, setCustomNodeEvents] = useState<CustomNodeEvent[]>(
|
||||
[],
|
||||
);
|
||||
const stream = useStream<State>({
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
assistantId: graphId,
|
||||
reconnectOnMount: true,
|
||||
threadId,
|
||||
onCustomEvent: (event) => {
|
||||
if (isCustomNodeEvent(event)) {
|
||||
setCustomNodeEvents((prev) => [...prev, event]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const initializeEvents = customNodeEvents.filter(
|
||||
(e) => e.nodeId === INITIALIZE_NODE_ID,
|
||||
);
|
||||
const steps = mapCustomEventsToSteps(initializeEvents);
|
||||
const allSuccess =
|
||||
steps.length > 0 && steps.every((s) => s.status === "success");
|
||||
|
||||
let initStatus: "loading" | "generating" | "done" = "generating";
|
||||
if (allSuccess) {
|
||||
initStatus = "done";
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const customInitEvents = getCustomNodeEventsFromMessages(
|
||||
stream.messages,
|
||||
INITIALIZE_NODE_ID,
|
||||
);
|
||||
// If there are no custom init events found in messages, or we already have steps from custom events, return
|
||||
if (!customInitEvents?.length || initializeEvents.length) {
|
||||
return;
|
||||
}
|
||||
setCustomNodeEvents(customInitEvents);
|
||||
}, [stream.messages]);
|
||||
|
||||
const streamJoined = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!streamJoined.current && runId) {
|
||||
streamJoined.current = true;
|
||||
// TODO: If the SDK changes go in, use this instead:
|
||||
// stream.joinStream(runId, undefined, { streamMode: ["values", "messages", "custom"]}).catch(console.error);
|
||||
stream.joinStream(runId).catch(console.error);
|
||||
}
|
||||
}, [runId]);
|
||||
|
||||
const nonHumanMessages = stream.messages?.filter(
|
||||
(m) => !isHumanMessageSDK(m),
|
||||
// Filter out human & do not render messages
|
||||
const filteredMessages = stream.messages?.filter(
|
||||
(m) =>
|
||||
!isHumanMessageSDK(m) &&
|
||||
!(m.id && m.id.startsWith(DO_NOT_RENDER_ID_PREFIX)),
|
||||
);
|
||||
|
||||
// TODO: Need a better way to handle this. Not great like this...
|
||||
|
|
@ -60,7 +128,15 @@ export function ActionsRenderer<State extends PlannerGraphState | GraphState>({
|
|||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{nonHumanMessages?.map((m) => (
|
||||
{initializeEvents.length > 0 && steps.length > 0 && (
|
||||
<InitializeStep
|
||||
status={initStatus}
|
||||
steps={steps}
|
||||
success={allSuccess}
|
||||
collapse={initStatus === "done" && allSuccess}
|
||||
/>
|
||||
)}
|
||||
{filteredMessages?.map((m) => (
|
||||
<AssistantMessage
|
||||
key={m.id}
|
||||
thread={stream as UseStream<Record<string, unknown>>}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import { Button } from "../ui/button";
|
|||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useGitHubAppProvider } from "@/providers/GitHubApp";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { useFileUpload } from "@/hooks/useFileUpload";
|
||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
||||
import { Base64ContentBlock, HumanMessage } from "@langchain/core/messages";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -46,16 +44,17 @@ export function TerminalInput({
|
|||
apiUrl,
|
||||
assistantId,
|
||||
reconnectOnMount: true,
|
||||
threadId: null,
|
||||
onThreadId: (id) => {
|
||||
push(`/chat/${id}`);
|
||||
setLoading(false);
|
||||
setMessage("");
|
||||
setContentBlocks([]);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSend = () => {
|
||||
const handleSend = async () => {
|
||||
const assistantId = process.env.NEXT_PUBLIC_MANAGER_ASSISTANT_ID;
|
||||
if (!assistantId) {
|
||||
toast.error("No assistant ID found", {
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!selectedRepository) {
|
||||
toast.error("Please select a repository first", {
|
||||
richColors: true,
|
||||
|
|
@ -76,25 +75,34 @@ export function TerminalInput({
|
|||
],
|
||||
});
|
||||
|
||||
stream.submit(
|
||||
{
|
||||
messages: [newHumanMessage],
|
||||
targetRepository: selectedRepository,
|
||||
},
|
||||
{
|
||||
streamMode: ["values"],
|
||||
optimisticValues: (prev) => ({
|
||||
...prev,
|
||||
messages: [...(prev.messages ?? []), newHumanMessage],
|
||||
}),
|
||||
try {
|
||||
const newThreadId = uuidv4();
|
||||
const run = await stream.client.runs.create(newThreadId, assistantId, {
|
||||
input: {
|
||||
messages: [newHumanMessage],
|
||||
targetRepository: selectedRepository,
|
||||
},
|
||||
config: {
|
||||
recursion_limit: 400,
|
||||
configurable: {
|
||||
...getConfig(DEFAULT_CONFIG_KEY),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
ifNotExists: "create",
|
||||
streamResumable: true,
|
||||
streamMode: ["values", "messages", "custom"],
|
||||
});
|
||||
|
||||
// set session storage so the stream can be resumed after redirect.
|
||||
sessionStorage.setItem(`lg:stream:${newThreadId}`, run.run_id);
|
||||
push(`/chat/${newThreadId}`);
|
||||
setMessage("");
|
||||
setContentBlocks([]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -108,7 +116,6 @@ export function TerminalInput({
|
|||
return (
|
||||
<div className="border-border bg-muted rounded-md border p-2 font-mono text-xs dark:bg-black">
|
||||
<div className="text-foreground flex items-start gap-1">
|
||||
{/* User@Host */}
|
||||
<span className="text-muted-foreground">open-swe</span>
|
||||
<span className="text-muted-foreground/70">@</span>
|
||||
<span className="text-muted-foreground">github</span>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
|||
import { ActionsRenderer } from "./actions-renderer";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { HumanMessage } from "@langchain/core/messages";
|
||||
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
|
||||
|
||||
const PROGRAMMER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PROGRAMMER_ASSISTANT_ID;
|
||||
const PLANNER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PLANNER_ASSISTANT_ID;
|
||||
|
|
@ -52,13 +53,26 @@ export function ThreadView({
|
|||
id: uuidv4(),
|
||||
content: chatInput,
|
||||
});
|
||||
stream.submit({
|
||||
messages: [newHumanMessage],
|
||||
});
|
||||
stream.submit(
|
||||
{
|
||||
messages: [newHumanMessage],
|
||||
},
|
||||
{
|
||||
streamResumable: true,
|
||||
optimisticValues: (prev) => ({
|
||||
...prev,
|
||||
messages: [...(prev.messages ?? []), newHumanMessage],
|
||||
}),
|
||||
},
|
||||
);
|
||||
setChatInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMessages = stream.messages.filter((message) => {
|
||||
return !message.id?.startsWith(DO_NOT_RENDER_ID_PREFIX);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-1 flex-col">
|
||||
{/* Header */}
|
||||
|
|
@ -105,7 +119,7 @@ export function ThreadView({
|
|||
<div className="border-border bg-muted/30 flex h-full w-1/3 flex-col border-r dark:bg-gray-950">
|
||||
{/* Chat Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{stream.messages.map((message) => (
|
||||
{filteredMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex gap-3"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Message, ToolMessage } from "@langchain/langgraph-sdk";
|
||||
|
||||
export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
|
||||
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
|
||||
|
||||
export function ensureToolCallsHaveResponses(messages: Message[]): Message[] {
|
||||
const newMessages: ToolMessage[] = [];
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import { Button } from "@/components/ui/button";
|
|||
import { GitHubSVG } from "@/components/icons/github";
|
||||
import { useGitHubToken } from "@/hooks/useGitHubToken";
|
||||
import { GraphState, GraphUpdate } from "@open-swe/shared/open-swe/types";
|
||||
import {
|
||||
CustomNodeEvent,
|
||||
isCustomNodeEvent,
|
||||
} from "@open-swe/shared/open-swe/custom-node-events";
|
||||
|
||||
const useTypedStream = useStream<
|
||||
GraphState,
|
||||
|
|
@ -32,7 +36,9 @@ const useTypedStream = useStream<
|
|||
}
|
||||
>;
|
||||
|
||||
type StreamContextType = ReturnType<typeof useTypedStream>;
|
||||
type StreamContextType = ReturnType<typeof useTypedStream> & {
|
||||
customEvents: CustomNodeEvent[];
|
||||
};
|
||||
const StreamContext = createContext<StreamContextType | undefined>(undefined);
|
||||
|
||||
async function sleep(ms = 4000) {
|
||||
|
|
@ -51,13 +57,17 @@ const StreamSession = ({
|
|||
githubToken: string;
|
||||
}) => {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const { refreshThreads, setThreads } = useThreadsContext();
|
||||
const [customEvents, setCustomEvents] = useState<CustomNodeEvent[]>([]);
|
||||
const { refreshThreads } = useThreadsContext();
|
||||
const streamValue = useTypedStream({
|
||||
apiUrl,
|
||||
assistantId,
|
||||
reconnectOnMount: true,
|
||||
threadId: threadId ?? null,
|
||||
onCustomEvent: (event, options) => {
|
||||
if (isCustomNodeEvent(event)) {
|
||||
setCustomEvents((prev) => [...prev, event]);
|
||||
}
|
||||
if (isUIMessage(event) || isRemoveUIMessage(event)) {
|
||||
options.mutate((prev) => {
|
||||
const ui = uiMessageReducer(prev.ui ?? [], event);
|
||||
|
|
@ -75,7 +85,12 @@ const StreamSession = ({
|
|||
});
|
||||
|
||||
return (
|
||||
<StreamContext.Provider value={streamValue}>
|
||||
<StreamContext.Provider
|
||||
value={{
|
||||
...streamValue,
|
||||
customEvents,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StreamContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,3 +7,5 @@ export const PLAN_INTERRUPT_ACTION_TITLE = "Approve/Edit Plan";
|
|||
// Prefix the access token with `x-` so that it's included in requests to the LangGraph server.
|
||||
export const GITHUB_TOKEN_COOKIE = "x-github-access-token";
|
||||
export const GITHUB_INSTALLATION_TOKEN_COOKIE = "x-github-installation-token";
|
||||
|
||||
export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
|
||||
|
|
|
|||
74
packages/shared/src/open-swe/custom-node-events.ts
Normal file
74
packages/shared/src/open-swe/custom-node-events.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
export type CustomNodeEvent = {
|
||||
/**
|
||||
* A UUID for the node the action is associated with.
|
||||
*/
|
||||
nodeId: string;
|
||||
/**
|
||||
* A UUID for the action the event is associated with.
|
||||
*/
|
||||
actionId: string;
|
||||
action: string;
|
||||
createdAt: string;
|
||||
data: {
|
||||
status: "pending" | "success" | "error" | "skipped";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export function isCustomNodeEvent(event: unknown): event is CustomNodeEvent {
|
||||
return (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"nodeId" in event &&
|
||||
"actionId" in event &&
|
||||
"action" in event &&
|
||||
"data" in event &&
|
||||
"createdAt" in event
|
||||
);
|
||||
}
|
||||
export const INITIALIZE_NODE_ID = "initialize";
|
||||
|
||||
export const INIT_STEPS = [
|
||||
"Resuming Sandbox",
|
||||
"Creating Sandbox",
|
||||
"Cloning repository",
|
||||
"Pulling latest changes",
|
||||
"Configuring git user",
|
||||
"Checking out branch",
|
||||
"Generating codebase tree",
|
||||
];
|
||||
|
||||
export type Step = {
|
||||
name: string;
|
||||
status: "waiting" | "generating" | "success" | "error" | "skipped";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps custom events to step objects for UI rendering. Skipped steps are filtered out.
|
||||
*/
|
||||
export function mapCustomEventsToSteps(events: CustomNodeEvent[]) {
|
||||
return INIT_STEPS.map((stepName) => {
|
||||
const event = [...events]
|
||||
.filter((e) => e.action === stepName)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0];
|
||||
if (!event) return { name: stepName, status: "waiting" as const };
|
||||
if (event.data.status === "skipped")
|
||||
return { name: stepName, status: "skipped" as const };
|
||||
if (event.data.status === "pending")
|
||||
return { name: stepName, status: "generating" as const };
|
||||
if (event.data.status === "success")
|
||||
return { name: stepName, status: "success" as const };
|
||||
if (event.data.status === "error")
|
||||
return {
|
||||
name: stepName,
|
||||
status: "error" as const,
|
||||
error:
|
||||
typeof event.data.error === "string" ? event.data.error : undefined,
|
||||
};
|
||||
return { name: stepName, status: "waiting" as const };
|
||||
}).filter((step) => step.status !== "skipped");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue