mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add AI Bridge sessions queries (#23385)
Introduces the query for paginated sessions
This commit is contained in:
@@ -2992,6 +2992,13 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIBridgeSessionList = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams("/api/v2/aibridge/sessions", options);
|
||||
const response =
|
||||
await this.axios.get<TypesGen.AIBridgeListSessionsResponse>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIBridgeModels = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { API } from "api/api";
|
||||
import type { AIBridgeListInterceptionsResponse } from "api/typesGenerated";
|
||||
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
|
||||
import { API } from "#/api/api";
|
||||
import type { AIBridgeListInterceptionsResponse } from "#/api/typesGenerated";
|
||||
import { useFilterParamsKey } from "#/components/Filter/Filter";
|
||||
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
|
||||
|
||||
export const paginatedInterceptions = (
|
||||
searchParams: URLSearchParams,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
getDefaultFilterProps,
|
||||
MockMenu,
|
||||
} from "#/components/Filter/storyHelpers";
|
||||
import { ListSessionsFilter } from "./ListSessionsFilter";
|
||||
|
||||
type FilterProps = ComponentProps<typeof ListSessionsFilter>;
|
||||
|
||||
const defaultFilterProps = getDefaultFilterProps<
|
||||
Pick<FilterProps, "filter" | "menus">
|
||||
>({
|
||||
query: "",
|
||||
values: {
|
||||
username: undefined,
|
||||
provider: undefined,
|
||||
},
|
||||
menus: {
|
||||
user: MockMenu,
|
||||
provider: MockMenu,
|
||||
},
|
||||
});
|
||||
|
||||
const meta: Meta<typeof ListSessionsFilter> = {
|
||||
title: "pages/AIBridgePage/ListSessionsFilter",
|
||||
component: ListSessionsFilter,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ListSessionsFilter>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
...defaultFilterProps,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithQuery: Story = {
|
||||
args: {
|
||||
...getDefaultFilterProps<Pick<FilterProps, "filter" | "menus">>({
|
||||
query: "initiator:me",
|
||||
values: {
|
||||
username: "me",
|
||||
provider: undefined,
|
||||
},
|
||||
menus: {
|
||||
user: MockMenu,
|
||||
provider: MockMenu,
|
||||
},
|
||||
used: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
...defaultFilterProps,
|
||||
menus: {
|
||||
user: { ...MockMenu, isInitializing: true },
|
||||
provider: MockMenu,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter";
|
||||
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
|
||||
import {
|
||||
ProviderFilter,
|
||||
type ProviderFilterMenu,
|
||||
} from "pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ProviderFilter";
|
||||
import type { FC } from "react";
|
||||
|
||||
interface ListSessionsFilterProps {
|
||||
filter: ReturnType<typeof useFilter>;
|
||||
error?: unknown;
|
||||
menus: {
|
||||
user: UserFilterMenu;
|
||||
provider: ProviderFilterMenu;
|
||||
};
|
||||
}
|
||||
|
||||
export const ListSessionsFilter: FC<ListSessionsFilterProps> = ({
|
||||
filter,
|
||||
error,
|
||||
menus,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-end">
|
||||
<span className="mr-2 text-content-secondary">Organization:</span>
|
||||
{/* TODO: add organization filter */}
|
||||
{/* <OrganizationAutocomplete */}
|
||||
{/* onChange={(org) => console.info("Selected organization", org)} */}
|
||||
{/* /> */}
|
||||
</div>
|
||||
<Filter
|
||||
filter={filter}
|
||||
optionsSkeleton={<MenuSkeleton />}
|
||||
isLoading={menus.user.isInitializing}
|
||||
presets={[
|
||||
{
|
||||
name: "All sessions",
|
||||
query: "",
|
||||
},
|
||||
{
|
||||
name: "My sessions",
|
||||
query: "initiator:me",
|
||||
},
|
||||
]}
|
||||
error={error}
|
||||
options={
|
||||
<>
|
||||
<UserMenu menu={menus.user} placeholder="All users" />
|
||||
<ProviderFilter menu={menus.provider} />
|
||||
{/* TODO: add client filter */}
|
||||
{/* <ClientFilter menu={menus.client} /> */}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,13 @@ export const AIBridgeProviderIcon = ({
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "anthropic-neue":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/anthropic.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "copilot":
|
||||
return (
|
||||
<ExternalImage
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MockSession } from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SessionSummaryTable } from "./SessionSummaryTable";
|
||||
|
||||
const meta: Meta<typeof SessionSummaryTable> = {
|
||||
title: "pages/AIBridgePage/SessionSummaryTable",
|
||||
component: SessionSummaryTable,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SessionSummaryTable>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
sessionId: MockSession.id,
|
||||
startTime: new Date(MockSession.started_at),
|
||||
endTime: new Date(MockSession.ended_at!),
|
||||
initiator: MockSession.initiator,
|
||||
client: MockSession.client ?? "Unknown",
|
||||
providers: MockSession.providers,
|
||||
inputTokens: MockSession.token_usage_summary.input_tokens,
|
||||
outputTokens: MockSession.token_usage_summary.output_tokens,
|
||||
threadCount: MockSession.threads,
|
||||
toolCallCount: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export const InProgress: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
endTime: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleProviders: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
providers: ["anthropic", "openai", "copilot"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTokenMetadata: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
tokenUsageMetadata: {
|
||||
cache_read_input_tokens: 3200,
|
||||
cache_creation_input_tokens: 800,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeTokenCounts: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
inputTokens: 198_000,
|
||||
outputTokens: 32_000,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { MinimalUser } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { AIBridgeClientIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";
|
||||
import { AIBridgeProviderIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";
|
||||
import { formatDateTime } from "utils/time";
|
||||
import { TokenBadges } from "../TokenBadges";
|
||||
import { getProviderDisplayName, getProviderIconName } from "../utils";
|
||||
|
||||
const Separator = () => (
|
||||
<div className="border-0 border-t border-solid border-border-content-secondary my-1" />
|
||||
);
|
||||
|
||||
interface SessionSummaryTableProps {
|
||||
sessionId: string;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
initiator: MinimalUser;
|
||||
client: string;
|
||||
providers: readonly string[];
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
threadCount: number;
|
||||
toolCallCount: number;
|
||||
tokenUsageMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const SessionSummaryTable = ({
|
||||
sessionId,
|
||||
startTime,
|
||||
endTime,
|
||||
initiator,
|
||||
providers,
|
||||
client,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
threadCount,
|
||||
toolCallCount,
|
||||
tokenUsageMetadata,
|
||||
}: SessionSummaryTableProps) => {
|
||||
const durationInMs =
|
||||
endTime != null
|
||||
? new Date(endTime).getTime() - new Date(startTime).getTime()
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="text-sm text-content-secondary flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">Session ID</span>
|
||||
<span
|
||||
className="text-content-primary font-mono truncate"
|
||||
title={sessionId}
|
||||
>
|
||||
{sessionId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">Start time</span>
|
||||
<span
|
||||
className="text-content-primary font-mono truncate"
|
||||
title={formatDateTime(startTime)}
|
||||
>
|
||||
{formatDateTime(startTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">End time</span>
|
||||
<span className="text-content-primary font-mono truncate">
|
||||
{endTime ? formatDateTime(endTime) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">Duration</span>
|
||||
<span
|
||||
className="text-content-primary font-mono truncate"
|
||||
title={durationInMs != null ? `${durationInMs} ms` : undefined}
|
||||
>
|
||||
{durationInMs != null ? `${Math.round(durationInMs / 1000)} s` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">Initiator</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={initiator.avatar_url}
|
||||
fallback={initiator.name}
|
||||
/>
|
||||
<span className="truncate" title={initiator.name}>
|
||||
{initiator.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">Client</span>
|
||||
<Badge className="gap-1.5 max-w-full">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeClientIcon client={client} className="size-icon-xs" />
|
||||
</div>
|
||||
<span className="truncate min-w-0" title={client ?? "Unknown"}>
|
||||
{client ?? "Unknown"}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">Provider</span>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{providers.map((p) => (
|
||||
<Badge key={p} className="gap-1.5 max-w-full">
|
||||
<AIBridgeProviderIcon
|
||||
provider={getProviderIconName(p)}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
<span
|
||||
className="truncate min-w-0"
|
||||
title={getProviderDisplayName(p)}
|
||||
>
|
||||
{getProviderDisplayName(p)}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">In / out tokens</span>
|
||||
<TokenBadges
|
||||
inputTokens={inputTokens}
|
||||
outputTokens={outputTokens}
|
||||
tokenUsageMetadata={tokenUsageMetadata}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">Threads</span>
|
||||
<Badge>{threadCount}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4 whitespace-nowrap">Tool calls</span>
|
||||
<Badge>{toolCallCount}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgenticLoopTable } from "./AgenticLoopTable";
|
||||
|
||||
const meta: Meta<typeof AgenticLoopTable> = {
|
||||
title: "pages/AIBridgePage/SessionTimeline/AgenticLoopTable",
|
||||
component: AgenticLoopTable,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgenticLoopTable>;
|
||||
|
||||
export const Short: Story = {
|
||||
args: {
|
||||
duration: 4_200,
|
||||
toolCalls: 3,
|
||||
inputTokens: 1234,
|
||||
outputTokens: 567,
|
||||
},
|
||||
};
|
||||
|
||||
export const Long: Story = {
|
||||
args: {
|
||||
duration: 125_000,
|
||||
toolCalls: 42,
|
||||
inputTokens: 150_000,
|
||||
outputTokens: 12_000,
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleToolCall: Story = {
|
||||
args: {
|
||||
duration: 980,
|
||||
toolCalls: 1,
|
||||
inputTokens: 320,
|
||||
outputTokens: 88,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoToolCalls: Story = {
|
||||
args: {
|
||||
duration: 500,
|
||||
toolCalls: 0,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { TokenBadges } from "../../TokenBadges";
|
||||
import { roundDurationDisplay } from "../../utils";
|
||||
|
||||
interface AgenticLoopTableProps {
|
||||
duration: number; // in seconds
|
||||
toolCalls: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AgenticLoopTable: FC<AgenticLoopTableProps> = ({
|
||||
duration,
|
||||
toolCalls,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, "text-sm text-content-secondary")}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4">In / out tokens</span>
|
||||
<TokenBadges inputTokens={inputTokens} outputTokens={outputTokens} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between my-2">
|
||||
<span className="pr-4">Tool calls</span>
|
||||
<span>{toolCalls}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4">Duration</span>
|
||||
<span title={`${duration}ms`}>{roundDurationDisplay(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PromptTable } from "./PromptTable";
|
||||
|
||||
const meta: Meta<typeof PromptTable> = {
|
||||
title: "pages/AIBridgePage/SessionTimeline/PromptTable",
|
||||
component: PromptTable,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PromptTable>;
|
||||
|
||||
export const Anthropic: Story = {
|
||||
args: {
|
||||
timestamp: new Date("2025-03-19T14:22:00Z"),
|
||||
model: "claude-sonnet-4-5",
|
||||
inputTokens: 1234,
|
||||
outputTokens: 567,
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenAI: Story = {
|
||||
args: {
|
||||
timestamp: new Date("2025-03-19T14:22:00Z"),
|
||||
model: "gpt-4o",
|
||||
inputTokens: 8192,
|
||||
outputTokens: 1024,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTokenMetadata: Story = {
|
||||
args: {
|
||||
timestamp: new Date("2025-03-19T14:22:00Z"),
|
||||
model: "claude-sonnet-4-5",
|
||||
inputTokens: 5000,
|
||||
outputTokens: 2500,
|
||||
tokenUsageMetadata: {
|
||||
cache_read_input_tokens: 3200,
|
||||
cache_creation_input_tokens: 800,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeTokenCounts: Story = {
|
||||
args: {
|
||||
timestamp: new Date("2025-03-19T14:22:00Z"),
|
||||
model: "claude-opus-4-5",
|
||||
inputTokens: 198_000,
|
||||
outputTokens: 8_000,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { AIBridgeModelIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeModelIcon";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatDate } from "utils/time";
|
||||
import { TokenBadges } from "../../TokenBadges";
|
||||
|
||||
interface PromptTableProps {
|
||||
timestamp: Date;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
tokenUsageMetadata?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PromptTable: FC<PromptTableProps> = ({
|
||||
timestamp,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
tokenUsageMetadata,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, "text-sm text-content-secondary")}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="pr-4">Timestamp</span>
|
||||
<span
|
||||
className="font-mono whitespace-nowrap truncate"
|
||||
title={formatDate(timestamp)}
|
||||
>
|
||||
{formatDate(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="pr-4">Model</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="gap-1.5">
|
||||
<AIBridgeModelIcon model={model} className="size-icon-xs" />
|
||||
<span className="truncate min-w-0">{model}</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{model}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4">In / out tokens</span>
|
||||
<TokenBadges
|
||||
inputTokens={inputTokens}
|
||||
outputTokens={outputTokens}
|
||||
tokenUsageMetadata={tokenUsageMetadata}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ToolCallTable } from "./ToolCallTable";
|
||||
|
||||
const meta: Meta<typeof ToolCallTable> = {
|
||||
title: "pages/AIBridgePage/SessionTimeline/ToolCallTable",
|
||||
component: ToolCallTable,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ToolCallTable>;
|
||||
|
||||
export const WithMCPServer: Story = {
|
||||
args: {
|
||||
timestamp: new Date("2025-03-19T14:22:00Z"),
|
||||
serverURL: "http://localhost:3000/mcp",
|
||||
inputTokens: 1234,
|
||||
outputTokens: 567,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutMCPServer: Story = {
|
||||
args: {
|
||||
timestamp: new Date("2025-03-19T14:22:00Z"),
|
||||
serverURL: "",
|
||||
inputTokens: 1234,
|
||||
outputTokens: 567,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTokenMetadata: Story = {
|
||||
args: {
|
||||
timestamp: new Date("2025-03-19T14:22:00Z"),
|
||||
serverURL: "https://mcp.example.com/api/v1",
|
||||
inputTokens: 5000,
|
||||
outputTokens: 2500,
|
||||
tokenUsageMetadata: {
|
||||
cache_read_input_tokens: 3200,
|
||||
cache_creation_input_tokens: 800,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongServerURL: Story = {
|
||||
args: {
|
||||
timestamp: new Date("2025-03-19T14:22:00Z"),
|
||||
serverURL:
|
||||
"https://very-long-mcp-server-hostname.internal.example.com/api/v2/mcp/tools",
|
||||
inputTokens: 800,
|
||||
outputTokens: 200,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { CopyButton } from "components/CopyButton/CopyButton";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatDate } from "utils/time";
|
||||
import { TokenBadges } from "../../TokenBadges";
|
||||
|
||||
interface ToolCallTableProps {
|
||||
timestamp: Date;
|
||||
serverURL: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
tokenUsageMetadata?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ToolCallTable: FC<ToolCallTableProps> = ({
|
||||
timestamp,
|
||||
serverURL,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
tokenUsageMetadata,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
"flex flex-col gap-2 text-xs text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between whitespace-nowrap">
|
||||
<span className="pr-4">In / out tokens</span>
|
||||
<TokenBadges
|
||||
inputTokens={inputTokens}
|
||||
outputTokens={outputTokens}
|
||||
tokenUsageMetadata={tokenUsageMetadata}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4">Started at</span>
|
||||
<span
|
||||
className="font-mono whitespace-nowrap truncate"
|
||||
title={formatDate(timestamp)}
|
||||
>
|
||||
{formatDate(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{serverURL && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4">MCP server</span>
|
||||
<span className="font-mono truncate">{serverURL}</span>
|
||||
<CopyButton text={serverURL} label="Copy MCP server URL" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { prettyFormatJSON, roundTokenDisplay } from "./utils";
|
||||
|
||||
interface TokenBadgesProps {
|
||||
size?: "xs" | "sm" | "md";
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
tokenUsageMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const TokenBadges: FC<TokenBadgesProps> = ({
|
||||
size = "sm",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
tokenUsageMetadata,
|
||||
}) => (
|
||||
<div className="flex items-center whitespace-nowrap">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge className="gap-0 rounded-e-none" size={size}>
|
||||
<ArrowDownIcon className="size-icon-lg flex-shrink-0" />
|
||||
<span className="truncate min-w-0">
|
||||
{roundTokenDisplay(inputTokens)}
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
className="gap-0 bg-surface-tertiary rounded-s-none"
|
||||
size={size}
|
||||
>
|
||||
<ArrowUpIcon className="size-icon-lg flex-shrink-0" />
|
||||
<span className="truncate min-w-0">
|
||||
{roundTokenDisplay(outputTokens)}
|
||||
</span>
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowDownIcon className="size-icon-sm flex-shrink-0" />
|
||||
<span className="text-content-primary text-sm">
|
||||
Input tokens
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-xs text-content-secondary">Input</div>
|
||||
<div className="text-xs text-content-secondary">
|
||||
{roundTokenDisplay(inputTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUpIcon className="size-icon-sm flex-shrink-0" />
|
||||
<span className="text-content-primary text-sm">
|
||||
Output tokens
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-xs text-content-secondary">Output</div>
|
||||
<div className="text-xs text-content-secondary">
|
||||
{roundTokenDisplay(outputTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tokenUsageMetadata && (
|
||||
<>
|
||||
<div className="text-content-primary text-sm mt-4">
|
||||
Token usage metadata
|
||||
</div>
|
||||
<pre className="mt-2 p-4 bg-surface-secondary rounded text-xs overflow-x-auto">
|
||||
{prettyFormatJSON(JSON.stringify(tokenUsageMetadata))}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
export const roundTokenDisplay = (tokens: number) => {
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return tokens.toString();
|
||||
};
|
||||
|
||||
export const roundDurationDisplay = (duration: number) => {
|
||||
if (duration >= 1000) {
|
||||
return `${(duration / 1000).toFixed(1)}s`;
|
||||
}
|
||||
return `${duration.toFixed(0)}ms`;
|
||||
};
|
||||
|
||||
export const getProviderDisplayName = (provider: string) => {
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
return "Anthropic";
|
||||
case "openai":
|
||||
return "OpenAI";
|
||||
case "copilot":
|
||||
return "Github";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
// FIXME the current AIBridgeProviderIcon uses the claude icon for the
|
||||
// anthropic provider. while it's still in use in the RequestLogsPage, we need
|
||||
// to hack around it here, but when we delete that page, we can just swap the
|
||||
// icon
|
||||
export const getProviderIconName = (provider: string) => {
|
||||
if (provider === "anthropic") {
|
||||
return "anthropic-neue";
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const prettyFormatJSON = (input: string) => {
|
||||
let formattedInput = input;
|
||||
|
||||
try {
|
||||
formattedInput = JSON.stringify(JSON.parse(input), null, 2);
|
||||
} catch {
|
||||
// not JSON, use as-is
|
||||
}
|
||||
|
||||
return formattedInput;
|
||||
};
|
||||
@@ -5279,3 +5279,29 @@ export const MockInterceptionCopilot: TypesGen.AIBridgeInterception = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MockSession: TypesGen.AIBridgeSession = {
|
||||
id: "c8f2df8c-149c-43e1-9d51-898daaa2c505",
|
||||
initiator: {
|
||||
id: "59da0bfe-9c99-47fa-a563-f9fdb18449d0",
|
||||
username: "bob",
|
||||
name: "The Builder, Bob",
|
||||
avatar_url:
|
||||
"https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQeDqc5b7Ny5bJOKxDeFvy17kBQ2_ZmBE8vKw&s",
|
||||
},
|
||||
providers: ["anthropic", "openai"],
|
||||
models: ["claude-opus-4-6", "gpt-5.4"],
|
||||
client: "Mux",
|
||||
metadata: {
|
||||
request_user_agent:
|
||||
"mux/0.20.1-next.8.g0f494106 ai-sdk/anthropic/3.0.58 ai-sdk/provider-utils/4.0.19 runtime/node.js/22",
|
||||
},
|
||||
started_at: "2026-03-09T09:28:15.03152Z",
|
||||
ended_at: "2026-03-09T10:28:17.294897Z",
|
||||
threads: 17,
|
||||
token_usage_summary: {
|
||||
input_tokens: 1234,
|
||||
output_tokens: 4321,
|
||||
},
|
||||
last_prompt: "But *can* I really fix it?",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user