diff --git a/apps/web/src/components/v2/actions-renderer.tsx b/apps/web/src/components/v2/actions-renderer.tsx index 3cfcd240..614d425b 100644 --- a/apps/web/src/components/v2/actions-renderer.tsx +++ b/apps/web/src/components/v2/actions-renderer.tsx @@ -1,27 +1,14 @@ 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, useMemo, useState } from "react"; import { - Dispatch, - SetStateAction, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types"; -import { useCancelStream } from "@/hooks/useCancelStream"; -import { - isCustomNodeEvent, CustomNodeEvent, INITIALIZE_NODE_ID, ACCEPTED_PLAN_NODE_ID, mapCustomEventsToSteps, } from "@open-swe/shared/open-swe/custom-node-events"; -import { - DO_NOT_RENDER_ID_PREFIX, - PLANNER_GRAPH_ID, -} from "@open-swe/shared/constants"; +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 { AcceptedPlanStep } from "../gen-ui/accepted-plan-step"; @@ -33,7 +20,6 @@ import { Interrupt } from "../thread/messages/interrupt"; import { AlertCircle } from "lucide-react"; import { ErrorState } from "./types"; import { CollapsibleAlert } from "./collapsible-alert"; -import { TokenUsage } from "./token-usage"; interface AcceptedPlanEventData { planTitle: string; @@ -72,16 +58,13 @@ function isAcceptedPlanEvents( return events.every(isAcceptedPlanEvent); } -interface ActionsRendererProps { - graphId: string; - threadId: string; +interface ActionsRendererProps< + StateType extends PlannerGraphState | GraphState, +> { runId?: string; - setProgrammerSession?: ( - session: ManagerGraphState["programmerSession"], - ) => void; - programmerSession?: ManagerGraphState["programmerSession"]; - setSelectedTab?: Dispatch>; - onStreamReady: (cancelFn: (() => void) | undefined) => void; + customNodeEvents: CustomNodeEvent[]; + setCustomNodeEvents: Dispatch>; + stream: ReturnType>; } const getCustomNodeEventsFromMessages = ( @@ -106,65 +89,17 @@ const getCustomNodeEventsFromMessages = ( .flat(); }; -export function ActionsRenderer({ - graphId, - threadId, +export function ActionsRenderer< + StateType extends PlannerGraphState | GraphState, +>({ runId, - setProgrammerSession, - programmerSession, - setSelectedTab, - onStreamReady, -}: ActionsRendererProps) { - const [customNodeEvents, setCustomNodeEvents] = useState( - [], - ); - const joinedRunId = useRef(undefined); - const [streamLoading, setStreamLoading] = useState(false); + customNodeEvents, + setCustomNodeEvents, + stream, +}: ActionsRendererProps) { + const [streamLoading, setStreamLoading] = useState(stream.isLoading); const [errorState, setErrorState] = useState(null); - const stream = useStream({ - apiUrl: process.env.NEXT_PUBLIC_API_URL, - assistantId: graphId, - reconnectOnMount: true, - threadId, - onCustomEvent: (event) => { - if (isCustomNodeEvent(event)) { - setCustomNodeEvents((prev) => [...prev, event]); - } - }, - fetchStateHistory: false, - }); - - useEffect(() => { - if (stream.error) { - const rawErrorMessage = - typeof stream.error === "object" && "message" in stream.error - ? (stream.error.message as string) - : "An unknown error occurred in the manager"; - - if (rawErrorMessage.includes("overloaded_error")) { - setErrorState({ - message: - "An Anthropic overloaded error occurred. This error occurs when Anthropic APIs experience high traffic across all users.", - details: rawErrorMessage, - }); - } else { - setErrorState({ - message: rawErrorMessage, - }); - } - } else { - setErrorState(null); - } - }, [stream.error]); - - const { cancelRun } = useCancelStream({ - stream, - threadId, - runId, - streamName: graphId === "planner" ? "Planner" : "Programmer", - }); - const initializeEvents = useMemo( () => customNodeEvents.filter( @@ -190,6 +125,20 @@ export function ActionsRenderer({ initStatus = "done"; } + // 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)), + ); + const isLastMessageHidden = !!( + stream.messages?.length > 0 && + stream.messages[stream.messages.length - 1].id && + stream.messages[stream.messages.length - 1].id?.startsWith( + DO_NOT_RENDER_ID_PREFIX, + ) + ); + useEffect(() => { const allCustomEvents = getCustomNodeEventsFromMessages(stream.messages); if (!allCustomEvents?.length) { @@ -219,79 +168,42 @@ export function ActionsRenderer({ // Clear streamLoading as soon as we get any content (agent has started running) useEffect(() => { const hasContent = - (stream.messages && stream.messages.length > 0) || - customNodeEvents.length > 0; + filteredMessages.length > 0 || customNodeEvents.length > 0; if (hasContent && streamLoading) { setStreamLoading(false); } }, [stream.messages, customNodeEvents, streamLoading]); - // TODO: If the SDK changes go in, use this instead: - // stream.joinStream(runId, undefined, { streamMode: ["values", "messages", "custom"]}).catch(console.error); useEffect(() => { - if (runId && runId !== joinedRunId.current) { - joinedRunId.current = runId; - setStreamLoading(true); - stream - .joinStream(runId) - .catch(console.error) - .finally(() => setStreamLoading(false)); - } else if (!runId) { - joinedRunId.current = undefined; - } - }, [runId, stream]); + if (stream.error) { + const rawErrorMessage = + typeof stream.error === "object" && "message" in stream.error + ? (stream.error.message as string) + : "An unknown error occurred in the manager"; - useEffect(() => { - if (stream.isLoading) { - onStreamReady(cancelRun); - } else { - onStreamReady(undefined); - } - }, [onStreamReady, runId]); // Depend on runId instead of cancelRun to avoid infinite loops - - // 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)), - ); - const isLastMessageHidden = !!( - stream.messages?.length > 0 && - stream.messages[stream.messages.length - 1].id && - stream.messages[stream.messages.length - 1].id?.startsWith( - DO_NOT_RENDER_ID_PREFIX, - ) - ); - - // TODO: Need a better way to handle this. Not great like this... - useEffect(() => { - if ( - "programmerSession" in stream.values && - stream.values.programmerSession && - (stream.values.programmerSession.runId !== programmerSession?.runId || - stream.values.programmerSession.threadId !== - programmerSession?.threadId) - ) { - setProgrammerSession?.(stream.values.programmerSession); - - // Only switch tabs from the planner ActionsRenderer to ensure proper timing - // This allows the accepted plan step to be visible before switching - if (graphId === PLANNER_GRAPH_ID) { - // Add a small delay to allow the accepted plan step to render first - setTimeout(() => { - setSelectedTab?.("programmer"); - }, 2000); + if (rawErrorMessage.includes("overloaded_error")) { + setErrorState({ + message: + "An Anthropic overloaded error occurred. This error occurs when Anthropic APIs experience high traffic across all users.", + details: rawErrorMessage, + }); + } else { + setErrorState({ + message: rawErrorMessage, + }); } + } else { + setErrorState(null); } - }, [stream.values, graphId]); + }, [stream.error]); - if (streamLoading) { + if (streamLoading && !errorState) { return ; } return ( -
+
{initializeEvents.length > 0 && steps.length > 0 && ( ({ icon={} /> ) : null} -
); } diff --git a/apps/web/src/components/v2/thread-view.tsx b/apps/web/src/components/v2/thread-view.tsx index 9594cb4d..3d0b8a67 100644 --- a/apps/web/src/components/v2/thread-view.tsx +++ b/apps/web/src/components/v2/thread-view.tsx @@ -11,7 +11,7 @@ import { ThreadMetadata } from "./types"; import { useStream } from "@langchain/langgraph-sdk/react"; import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types"; import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types"; -import { GraphState } from "@open-swe/shared/open-swe/types"; +import { GraphState, CacheMetrics } from "@open-swe/shared/open-swe/types"; import { ActionsRenderer } from "./actions-renderer"; import { ThemeToggle } from "../theme-toggle"; import { HumanMessage } from "@langchain/core/messages"; @@ -23,7 +23,6 @@ import { import { useThreadStatus } from "@/hooks/useThreadStatus"; import { cn } from "@/lib/utils"; -import { StickToBottom } from "use-stick-to-bottom"; import { StickyToBottomContent, ScrollToBottom, @@ -31,6 +30,12 @@ import { import { ManagerChat } from "./manager-chat"; import { CancelStreamButton } from "./cancel-stream-button"; import { ErrorState } from "./types"; +import { + CustomNodeEvent, + isCustomNodeEvent, +} from "@open-swe/shared/open-swe/custom-node-events"; +import { StickToBottom } from "use-stick-to-bottom"; +import { TokenUsage } from "./token-usage"; interface ThreadViewProps { stream: ReturnType>; @@ -55,6 +60,65 @@ export function ThreadView({ useState(); const [errorState, setErrorState] = useState(null); + const [customPlannerNodeEvents, setCustomPlannerNodeEvents] = useState< + CustomNodeEvent[] + >([]); + const [customProgrammerNodeEvents, setCustomProgrammerNodeEvents] = useState< + CustomNodeEvent[] + >([]); + + const plannerStream = useStream({ + apiUrl: process.env.NEXT_PUBLIC_API_URL, + assistantId: PLANNER_GRAPH_ID, + reconnectOnMount: true, + threadId: plannerSession?.threadId, + onCustomEvent: (event) => { + if (isCustomNodeEvent(event)) { + setCustomPlannerNodeEvents((prev) => [...prev, event]); + } + }, + fetchStateHistory: false, + }); + + const joinedPlannerRunId = useRef(undefined); + useEffect(() => { + if ( + plannerSession?.runId && + plannerSession.runId !== joinedPlannerRunId.current + ) { + joinedPlannerRunId.current = plannerSession.runId; + plannerStream.joinStream(plannerSession.runId).catch(console.error); + } else if (!plannerSession?.runId) { + joinedPlannerRunId.current = undefined; + } + }, [plannerSession]); + + const programmerStream = useStream({ + apiUrl: process.env.NEXT_PUBLIC_API_URL, + assistantId: PROGRAMMER_GRAPH_ID, + reconnectOnMount: true, + threadId: programmerSession?.threadId, + onCustomEvent: (event) => { + if (isCustomNodeEvent(event)) { + setCustomProgrammerNodeEvents((prev) => [...prev, event]); + } + }, + fetchStateHistory: false, + }); + + const joinedProgrammerRunId = useRef(undefined); + useEffect(() => { + if ( + programmerSession?.runId && + programmerSession.runId !== joinedProgrammerRunId.current + ) { + joinedProgrammerRunId.current = programmerSession.runId; + plannerStream.joinStream(programmerSession.runId).catch(console.error); + } else if (!programmerSession?.runId) { + joinedProgrammerRunId.current = undefined; + } + }, [programmerSession]); + useEffect(() => { if ( stream?.values?.plannerSession && @@ -94,6 +158,27 @@ export function ThreadView({ } }, [stream.error]); + useEffect(() => { + if ( + plannerStream.values.programmerSession && + (plannerStream.values.programmerSession.runId !== + programmerSession?.runId || + plannerStream.values.programmerSession.threadId !== + programmerSession?.threadId) + ) { + setProgrammerSession?.(plannerStream.values.programmerSession); + + // Only switch tabs from the planner ActionsRenderer to ensure proper timing + // This allows the accepted plan step to be visible before switching + if (selectedTab === PLANNER_GRAPH_ID) { + // Add a small delay to allow the accepted plan step to render first + setTimeout(() => { + setSelectedTab?.("programmer"); + }, 2000); + } + } + }, [plannerStream.values, selectedTab]); + const { status: realTimeStatus } = useThreadStatus(displayThread.id); const getStatusDotColor = (status: string) => { @@ -111,9 +196,6 @@ export function ThreadView({ } }; - const plannerCancelRef = useRef<(() => void) | null>(null); - const programmerCancelRef = useRef<(() => void) | null>(null); - const cancelRun = () => { // TODO: ideally this calls stream.client.runs.cancel(threadId, runId) stream.stop(); @@ -187,7 +269,7 @@ export function ThreadView({
{/* Main Content - Split Layout */} -
+
{/* Right Side - Actions & Plan */} -
-
- +
+ + setSelectedTab(value as "planner" | "programmer") + } > - - setSelectedTab(value as "planner" | "programmer") +
+ + Planner + Programmer + + +
+ {selectedTab === "planner" && plannerStream.isLoading && ( + + )} + + {selectedTab === "programmer" && + programmerStream.isLoading && ( + + )} + -
- - Planner - Programmer - + /> +
+
-
- {selectedTab === "planner" && - plannerCancelRef.current && ( - - )} - - {selectedTab === "programmer" && - programmerCancelRef.current && ( - - )} -
-
- - - - - {plannerSession && ( - - graphId={PLANNER_GRAPH_ID} - threadId={plannerSession.threadId} - runId={plannerSession.runId} - setProgrammerSession={setProgrammerSession} - programmerSession={programmerSession} - setSelectedTab={setSelectedTab} - onStreamReady={(cancelFn) => { - if (cancelFn) { - plannerCancelRef.current = cancelFn; - } else { - plannerCancelRef.current = null; - } - }} - /> - )} - {!plannerSession && ( -
- - - No planner session - -
- )} -
-
-
- - - - {programmerSession && ( - - graphId={PROGRAMMER_GRAPH_ID} - threadId={programmerSession.threadId} - runId={programmerSession.runId} - onStreamReady={(cancelFn) => { - if (cancelFn) { - programmerCancelRef.current = cancelFn; - } else { - programmerCancelRef.current = null; - } - }} - /> - )} - {!programmerSession && ( -
- - - No programmer session - -
- )} -
-
-
-
- } - footer={ -
- -
- } - /> - + + + + + + {plannerSession ? ( +
+ + runId={plannerSession.runId} + customNodeEvents={customPlannerNodeEvents} + setCustomNodeEvents={ + setCustomPlannerNodeEvents + } + stream={plannerStream} + /> +
+ ) : ( +
+ + + No planner session + +
+ )} + + } + footer={ +
+ +
+ } + /> +
+
+
+
+ + + + + + {programmerSession ? ( +
+ + runId={programmerSession.runId} + customNodeEvents={customProgrammerNodeEvents} + setCustomNodeEvents={ + setCustomProgrammerNodeEvents + } + stream={programmerStream} + /> +
+ ) : ( +
+ + + No programmer session + +
+ )} + + } + footer={ +
+ +
+ } + /> +
+
+
+
+
diff --git a/apps/web/src/components/v2/token-usage.tsx b/apps/web/src/components/v2/token-usage.tsx index 377467f5..407ca974 100644 --- a/apps/web/src/components/v2/token-usage.tsx +++ b/apps/web/src/components/v2/token-usage.tsx @@ -1,54 +1,135 @@ import { CacheMetrics } from "@open-swe/shared/open-swe/types"; import { calculateCostSavings } from "@open-swe/shared/caching"; +import { Badge } from "../ui/badge"; +import { Separator } from "../ui/separator"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../ui/tooltip"; -import { ChartNoAxesColumnIncreasing } from "lucide-react"; + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "../ui/hover-card"; +import { + ChartNoAxesColumnIncreasing, + Coins, + TrendingUp, + Zap, +} from "lucide-react"; -export function TokenUsage({ tokenData }: { tokenData?: CacheMetrics }) { - if (!tokenData) return null; +interface TokenUsageProps { + tokenData?: CacheMetrics[]; +} - const metrics = calculateCostSavings(tokenData); - return ( -
- - - - - - -

Token usage data on actions where caching is enabled:

- -

Input Tokens:

-

{metrics.totalInputTokens.toLocaleString()}

-
- -

Output Tokens:

-

{metrics.totalOutputTokens.toLocaleString()}

-
- -

Total Tokens:

-

{metrics.totalTokens.toLocaleString()}

-
- - -

Output Tokens Cost:

-

${metrics.totalOutputTokensCost.toFixed(2)}

-
- -

Cache Savings:

-

${metrics.totalSavings.toFixed(2)}

-
- -

Total Cost:

-

${metrics.totalCost.toFixed(2)}

-
-
-
-
-
+function mergeTokenData(tokenDataArray: CacheMetrics[]): CacheMetrics { + return tokenDataArray.reduce( + (merged, current) => ({ + cacheCreationInputTokens: + merged.cacheCreationInputTokens + current.cacheCreationInputTokens, + cacheReadInputTokens: + merged.cacheReadInputTokens + current.cacheReadInputTokens, + inputTokens: merged.inputTokens + current.inputTokens, + outputTokens: merged.outputTokens + current.outputTokens, + }), + { + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + inputTokens: 0, + outputTokens: 0, + }, + ); +} + +export function TokenUsage({ tokenData }: TokenUsageProps) { + if (!tokenData || tokenData.length === 0) return null; + + const mergedTokenData = mergeTokenData(tokenData); + const metrics = calculateCostSavings(mergedTokenData); + + return ( + + + + + +
+
+ +

Token Usage

+
+ +
+
+
+ + + Input + +
+

+ {metrics.totalInputTokens.toLocaleString()} +

+
+
+
+ + + Output + +
+

+ {metrics.totalOutputTokens.toLocaleString()} +

+
+
+ + + +
+
+ + Total Tokens + + + {metrics.totalTokens.toLocaleString()} + +
+ +
+
+
+ + + Cost + +
+ + ${metrics.totalCost.toFixed(2)} + +
+ + {metrics.totalSavings > 0 && ( +
+ + Cache Savings + + + -${metrics.totalSavings.toFixed(2)} + +
+ )} +
+
+
+
+
); }