[FrontEnd] Display API cost and token usage in frontend (#7099)

Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
AutoLTX
2025-03-22 04:43:53 +08:00
committed by GitHub
parent ce26f1c6d3
commit 3bc52cad7b
9 changed files with 414 additions and 140 deletions

View File

@@ -1,4 +1,4 @@
import { render, screen, within } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import {
afterAll,
afterEach,
@@ -10,6 +10,7 @@ import {
vi,
} from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
import { clickOnEditButton } from "./utils";
@@ -20,7 +21,11 @@ describe("ConversationCard", () => {
const onChangeTitle = vi.fn();
beforeAll(() => {
vi.stubGlobal("window", { open: vi.fn() });
vi.stubGlobal("window", {
open: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});
afterEach(() => {
@@ -32,7 +37,7 @@ describe("ConversationCard", () => {
});
it("should render the conversation card", () => {
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -51,7 +56,7 @@ describe("ConversationCard", () => {
});
it("should render the selectedRepository if available", () => {
const { rerender } = render(
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -82,7 +87,7 @@ describe("ConversationCard", () => {
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -107,7 +112,7 @@ describe("ConversationCard", () => {
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -131,7 +136,7 @@ describe("ConversationCard", () => {
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -152,7 +157,7 @@ describe("ConversationCard", () => {
test("conversation title should call onChangeTitle when changed and blurred", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -181,7 +186,7 @@ describe("ConversationCard", () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -205,7 +210,7 @@ describe("ConversationCard", () => {
test("clicking the title should trigger the onClick handler", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
@@ -225,7 +230,7 @@ describe("ConversationCard", () => {
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -246,7 +251,7 @@ describe("ConversationCard", () => {
test("clicking the delete button should not trigger the onClick handler", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -268,11 +273,80 @@ describe("ConversationCard", () => {
expect(onClick).not.toHaveBeenCalled();
});
it("should show display cost button only when showDisplayCostOption is true", async () => {
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("display-cost-button"),
).not.toBeInTheDocument();
// Close menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
showDisplayCostOption
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
// Open menu again
await user.click(ellipsisButton);
// Wait for context menu to appear and check for display cost button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
});
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showDisplayCostOption
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const displayCostButton = within(menu).getByTestId("display-cost-button");
await user.click(displayCostButton);
// Verify if metrics modal is displayed by checking for the modal content
expect(screen.getByText("Metrics Information")).toBeInTheDocument();
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const user = userEvent.setup();
const { rerender } = render(
const { rerender } = renderWithProviders(
<ConversationCard
onClick={onClick}
onChangeTitle={onChangeTitle}
@@ -285,8 +359,9 @@ describe("ConversationCard", () => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
expect(screen.queryByTestId("edit-button")).toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
const menu = await screen.findByTestId("context-menu");
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
// toggle to hide the context menu
await user.click(ellipsisButton);
@@ -302,13 +377,15 @@ describe("ConversationCard", () => {
);
await user.click(ellipsisButton);
expect(screen.queryByTestId("edit-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).toBeInTheDocument();
const newMenu = await screen.findByTestId("context-menu");
expect(
within(newMenu).queryByTestId("edit-button"),
).not.toBeInTheDocument();
expect(within(newMenu).queryByTestId("delete-button")).toBeInTheDocument();
});
it("should not render the ellipsis button if there are no actions", () => {
const { rerender } = render(
const { rerender } = renderWithProviders(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
@@ -347,7 +424,7 @@ describe("ConversationCard", () => {
describe("state indicator", () => {
it("should render the 'STOPPED' indicator by default", () => {
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -362,7 +439,7 @@ describe("ConversationCard", () => {
});
it("should render the other indicators when provided", () => {
render(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
QueryClientProvider,
@@ -13,6 +13,7 @@ import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { clickOnEditButton } from "./utils";
import { queryClientConfig } from "#/query-client-config";
import { renderWithProviders } from "test-utils";
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
@@ -24,14 +25,13 @@ describe("ConversationPanel", () => {
]);
const renderConversationPanel = (config?: QueryClientConfig) =>
render(<RouterStub />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(config)}>
{children}
</QueryClientProvider>
</AuthProvider>
),
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
usage: null
}
}
});
const { endSessionMock } = vi.hoisted(() => ({
@@ -53,9 +53,38 @@ describe("ConversationPanel", () => {
}));
});
const mockConversations = [
{
conversation_id: "1",
title: "Conversation 1",
selected_repository: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "STOPPED" as const,
},
{
conversation_id: "2",
title: "Conversation 2",
selected_repository: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STOPPED" as const,
},
{
conversation_id: "3",
title: "Conversation 3",
selected_repository: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
// Setup default mock for getUserConversations
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([...mockConversations]);
});
it("should render the conversations", async () => {
@@ -83,13 +112,7 @@ describe("ConversationPanel", () => {
new Error("Failed to fetch conversations"),
);
renderConversationPanel({
defaultOptions: {
queries: {
retry: false,
},
},
});
renderConversationPanel();
const error = await screen.findByText("Failed to fetch conversations");
expect(error).toBeInTheDocument();
@@ -124,6 +147,20 @@ describe("ConversationPanel", () => {
it("should call endSession after deleting a conversation that is the current session", async () => {
const user = userEvent.setup();
const mockData = [...mockConversations];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
const deleteUserConversationSpy = vi.spyOn(OpenHands, "deleteUserConversation");
deleteUserConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex(conv => conv.conversation_id === id);
if (index !== -1) {
mockData.splice(index, 1);
}
// Wait for React Query to update its cache
await new Promise(resolve => setTimeout(resolve, 0));
});
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
@@ -140,18 +177,60 @@ describe("ConversationPanel", () => {
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
// Ensure the conversation is deleted
cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(2);
// Wait for the cards to update with a longer timeout
await waitFor(() => {
const updatedCards = screen.getAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(2);
}, { timeout: 2000 });
expect(endSessionMock).toHaveBeenCalledOnce();
});
it("should delete a conversation", async () => {
const user = userEvent.setup();
const mockData = [
{
conversation_id: "1",
title: "Conversation 1",
selected_repository: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "STOPPED" as const,
},
{
conversation_id: "2",
title: "Conversation 2",
selected_repository: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STOPPED" as const,
},
{
conversation_id: "3",
title: "Conversation 3",
selected_repository: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
const deleteUserConversationSpy = vi.spyOn(OpenHands, "deleteUserConversation");
deleteUserConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex(conv => conv.conversation_id === id);
if (index !== -1) {
mockData.splice(index, 1);
}
});
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const deleteButton = screen.getByTestId("delete-button");
@@ -165,9 +244,11 @@ describe("ConversationPanel", () => {
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
// Ensure the conversation is deleted
cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(1);
// Wait for the cards to update
await waitFor(() => {
const updatedCards = screen.getAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(2);
});
});
it("should rename a conversation", async () => {
@@ -189,7 +270,7 @@ describe("ConversationPanel", () => {
await user.tab();
// Ensure the conversation is renamed
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
expect(updateUserConversationSpy).toHaveBeenCalledWith("1", {
title: "Conversation 1 Renamed",
});
});
@@ -214,7 +295,7 @@ describe("ConversationPanel", () => {
// Ensure the conversation is not renamed
expect(updateUserConversationSpy).not.toHaveBeenCalled();
await clickOnEditButton(user);
await clickOnEditButton(user, card);
await user.type(title, "Conversation 1");
await user.click(title);
@@ -229,17 +310,21 @@ describe("ConversationPanel", () => {
});
it("should call onClose after clicking a card", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
const firstCard = cards[1];
await userEvent.click(firstCard);
await user.click(firstCard);
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should refetch data on rerenders", async () => {
// We need to simulate the toggling of the component to test the refetching
const user = userEvent.setup();
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue([...mockConversations]);
function PanelWithToggle() {
const [isOpen, setIsOpen] = React.useState(true);
return (
@@ -259,25 +344,28 @@ describe("ConversationPanel", () => {
},
]);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
render(<MyRouterStub />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(queryClientConfig)}>
{children}
</QueryClientProvider>
</AuthProvider>
),
renderWithProviders(<MyRouterStub />, {
preloadedState: {
metrics: {
cost: null,
usage: null
}
}
});
await waitFor(() => expect(getUserConversationsSpy).toHaveBeenCalledOnce());
const toggleButton = screen.getByText("Toggle");
const button = screen.getByText("Toggle");
await userEvent.click(button);
await userEvent.click(button);
// Initial render
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
await waitFor(() =>
expect(getUserConversationsSpy).toHaveBeenCalledTimes(2),
);
// Toggle off
await user.click(toggleButton);
expect(screen.queryByTestId("conversation-card")).not.toBeInTheDocument();
// Toggle on
await user.click(toggleButton);
const newCards = await screen.findAllByTestId("conversation-card");
expect(newCards).toHaveLength(3);
});
});

View File

@@ -30,6 +30,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
<ConversationCard
variant="compact"
showDisplayCostOption
title={conversation?.title ?? ""}
lastUpdatedAt={conversation?.created_at ?? ""}
selectedRepository={conversation?.selected_repository ?? null}

View File

@@ -7,6 +7,7 @@ interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
@@ -15,6 +16,7 @@ export function ConversationCardContextMenu({
onClose,
onDelete,
onEdit,
onDisplayCost,
onDownloadViaVSCode,
position = "bottom",
}: ConversationCardContextMenuProps) {
@@ -48,6 +50,14 @@ export function ConversationCardContextMenu({
Download via VS Code
</ContextMenuListItem>
)}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"
onClick={onDisplayCost}
>
Display Cost
</ContextMenuListItem>
)}
</ContextMenu>
);
}

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useSelector } from "react-redux";
import posthog from "posthog-js";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationRepoLink } from "./conversation-repo-link";
@@ -9,11 +10,14 @@ import {
import { EllipsisButton } from "./ellipsis-button";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
import { cn } from "#/utils/utils";
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
import { RootState } from "#/store";
interface ConversationCardProps {
onClick?: () => void;
onDelete?: () => void;
onChangeTitle?: (title: string) => void;
showDisplayCostOption?: boolean;
isActive?: boolean;
title: string;
selectedRepository: string | null;
@@ -27,6 +31,7 @@ export function ConversationCard({
onClick,
onDelete,
onChangeTitle,
showDisplayCostOption,
isActive,
title,
selectedRepository,
@@ -37,10 +42,11 @@ export function ConversationCard({
}: ConversationCardProps) {
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
// We don't use the VS Code URL hook directly here to avoid test failures
// Instead, we'll add the download button conditionally
// Subscribe to metrics data from Redux store
const metrics = useSelector((state: RootState) => state.metrics);
const handleBlur = () => {
if (inputRef.current?.value) {
@@ -110,93 +116,125 @@ export function ConversationCard({
setContextMenuVisible(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setMetricsModalVisible(true);
};
React.useEffect(() => {
if (titleMode === "edit") {
inputRef.current?.focus();
}
}, [titleMode]);
const hasContextMenu = !!(
onDelete ||
onChangeTitle ||
conversationId // If we have a conversation ID, we can show the download button
);
const hasContextMenu = !!(onDelete || onChangeTitle || showDisplayCostOption);
return (
<div
data-testid="conversation-card"
onClick={onClick}
className={cn(
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"h-auto w-fit rounded-xl border border-[#525252]",
)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{isActive && (
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
)}
{titleMode === "edit" && (
<input
ref={inputRef}
data-testid="conversation-card-title"
onClick={handleInputClick}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
type="text"
defaultValue={title}
className="text-sm leading-6 font-semibold bg-transparent w-full"
/>
)}
{titleMode === "view" && (
<p
data-testid="conversation-card-title"
className="text-sm leading-6 font-semibold bg-transparent truncate overflow-hidden"
title={title}
>
{title}
</p>
)}
</div>
<div className="flex items-center gap-2 relative">
<ConversationStateIndicator status={status} />
{hasContextMenu && (
<EllipsisButton
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
}}
/>
)}
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={
conversationId ? handleDownloadViaVSCode : undefined
}
position={variant === "compact" ? "top" : "bottom"}
/>
)}
</div>
</div>
<>
<div
data-testid="conversation-card"
onClick={onClick}
className={cn(
variant === "compact" && "flex items-center justify-between mt-1",
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"h-auto w-fit rounded-xl border border-[#525252]",
)}
>
{selectedRepository && (
<ConversationRepoLink selectedRepository={selectedRepository} />
)}
<p className="text-xs text-neutral-400">
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
</p>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{isActive && (
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
)}
{titleMode === "edit" && (
<input
ref={inputRef}
data-testid="conversation-card-title"
onClick={handleInputClick}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
type="text"
defaultValue={title}
className="text-sm leading-6 font-semibold bg-transparent w-full"
/>
)}
{titleMode === "view" && (
<p
data-testid="conversation-card-title"
className="text-sm leading-6 font-semibold bg-transparent truncate overflow-hidden"
title={title}
>
{title}
</p>
)}
</div>
<div className="flex items-center gap-2 relative">
<ConversationStateIndicator status={status} />
{hasContextMenu && (
<EllipsisButton
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
}}
/>
)}
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={
conversationId ? handleDownloadViaVSCode : undefined
}
onDisplayCost={
showDisplayCostOption ? handleDisplayCost : undefined
}
position={variant === "compact" ? "top" : "bottom"}
/>
)}
</div>
</div>
<div
className={cn(
variant === "compact" && "flex items-center justify-between mt-1",
)}
>
{selectedRepository && (
<ConversationRepoLink selectedRepository={selectedRepository} />
)}
<p className="text-xs text-neutral-400">
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
</p>
</div>
</div>
</div>
<BaseModal
isOpen={metricsModalVisible}
onOpenChange={setMetricsModalVisible}
title="Metrics Information"
testID="metrics-modal"
>
<div className="space-y-2">
{metrics?.cost !== null && (
<p>Total Cost: ${metrics.cost.toFixed(4)}</p>
)}
{metrics?.usage !== null && (
<>
<p>Tokens Used:</p>
<ul className="list-inside space-y-1 ml-2">
<li>- Input: {metrics.usage.prompt_tokens}</li>
<li>- Output: {metrics.usage.completion_tokens}</li>
<li>- Total: {metrics.usage.total_tokens}</li>
</ul>
</>
)}
{!metrics?.cost && !metrics?.usage && (
<p className="text-neutral-400">No metrics data available</p>
)}
</div>
</BaseModal>
</>
);
}

View File

@@ -9,6 +9,7 @@ import { appendSecurityAnalyzerInput } from "#/state/security-analyzer-slice";
import { setCode, setActiveFilepath } from "#/state/code-slice";
import { appendJupyterInput } from "#/state/jupyter-slice";
import { setCurStatusMessage } from "#/state/status-slice";
import { setMetrics } from "#/state/metrics-slice";
import store from "#/store";
import ActionType from "#/types/action-type";
import {
@@ -85,6 +86,18 @@ export function handleActionMessage(message: ActionMessage) {
return;
}
// Update metrics if available
if (
message.llm_metrics ||
message.tool_call_metadata?.model_response?.usage
) {
const metrics = {
cost: message.llm_metrics?.accumulated_cost ?? null,
usage: message.tool_call_metadata?.model_response?.usage ?? null,
};
store.dispatch(setMetrics(metrics));
}
if (message.action === ActionType.RUN) {
store.dispatch(appendInput(message.args.command));
}

View File

@@ -0,0 +1,29 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface MetricsState {
cost: number | null;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
} | null;
}
const initialState: MetricsState = {
cost: null,
usage: null,
};
const metricsSlice = createSlice({
name: "metrics",
initialState,
reducers: {
setMetrics: (state, action: PayloadAction<MetricsState>) => {
state.cost = action.payload.cost;
state.usage = action.payload.usage;
},
},
});
export const { setMetrics } = metricsSlice.actions;
export default metricsSlice.reducer;

View File

@@ -9,6 +9,7 @@ import commandReducer from "./state/command-slice";
import { jupyterReducer } from "./state/jupyter-slice";
import securityAnalyzerReducer from "./state/security-analyzer-slice";
import statusReducer from "./state/status-slice";
import metricsReducer from "./state/metrics-slice";
export const rootReducer = combineReducers({
fileState: fileStateReducer,
@@ -21,6 +22,7 @@ export const rootReducer = combineReducers({
jupyter: jupyterReducer,
securityAnalyzer: securityAnalyzerReducer,
status: statusReducer,
metrics: metricsReducer,
});
const store = configureStore({

View File

@@ -15,6 +15,22 @@ export interface ActionMessage {
// The timestamp of the message
timestamp: string;
// LLM metrics information
llm_metrics?: {
accumulated_cost: number;
};
// Tool call metadata
tool_call_metadata?: {
model_response?: {
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};
};
}
export interface ObservationMessage {