mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add AI sessions list page (#23388)
<!-- If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting. --> Adds the AI Bridge sessions list page.
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import { API } from "#/api/api";
|
import { API } from "#/api/api";
|
||||||
import type { AIBridgeListInterceptionsResponse } from "#/api/typesGenerated";
|
import type {
|
||||||
|
AIBridgeListInterceptionsResponse,
|
||||||
|
AIBridgeListSessionsResponse,
|
||||||
|
} from "#/api/typesGenerated";
|
||||||
import { useFilterParamsKey } from "#/components/Filter/Filter";
|
import { useFilterParamsKey } from "#/components/Filter/Filter";
|
||||||
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
|
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
|
||||||
|
|
||||||
@@ -20,3 +23,21 @@ export const paginatedInterceptions = (
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const paginatedSessions = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): UsePaginatedQueryOptions<AIBridgeListSessionsResponse, string> => {
|
||||||
|
return {
|
||||||
|
searchParams,
|
||||||
|
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
|
||||||
|
queryKey: ({ payload, pageNumber }) => {
|
||||||
|
return ["aiBridgeSessions", payload, pageNumber] as const;
|
||||||
|
},
|
||||||
|
queryFn: ({ offset, limit, payload }) =>
|
||||||
|
API.getAIBridgeSessionList({
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
q: payload,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Link } from "components/Link/Link";
|
||||||
|
import { Margins } from "components/Margins/Margins";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PageHeaderSubtitle,
|
||||||
|
PageHeaderTitle,
|
||||||
|
} from "components/PageHeader/PageHeader";
|
||||||
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
const AIBridgeSessionsLayout: FC<PropsWithChildren> = () => {
|
||||||
|
return (
|
||||||
|
<Margins className="pb-12">
|
||||||
|
<PageHeader>
|
||||||
|
<PageHeaderTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>AI Sessions</span>
|
||||||
|
</div>
|
||||||
|
</PageHeaderTitle>
|
||||||
|
<PageHeaderSubtitle>
|
||||||
|
Centralized auditing for LLM usage across your organization.{" "}
|
||||||
|
<Link
|
||||||
|
href="https://coder.com/docs/ai-coder/ai-governance"
|
||||||
|
className="ml-auto"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
More about AI Governance
|
||||||
|
</Link>
|
||||||
|
</PageHeaderSubtitle>
|
||||||
|
</PageHeader>
|
||||||
|
<Outlet />
|
||||||
|
</Margins>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIBridgeSessionsLayout;
|
||||||
@@ -21,38 +21,29 @@ export const ListSessionsFilter: FC<ListSessionsFilterProps> = ({
|
|||||||
menus,
|
menus,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Filter
|
||||||
<div className="mb-4 flex items-center justify-end">
|
filter={filter}
|
||||||
<span className="mr-2 text-content-secondary">Organization:</span>
|
optionsSkeleton={<MenuSkeleton />}
|
||||||
{/* TODO: add organization filter */}
|
isLoading={menus.user.isInitializing}
|
||||||
{/* <OrganizationAutocomplete */}
|
presets={[
|
||||||
{/* onChange={(org) => console.info("Selected organization", org)} */}
|
{
|
||||||
{/* /> */}
|
name: "All sessions",
|
||||||
</div>
|
query: "",
|
||||||
<Filter
|
},
|
||||||
filter={filter}
|
{
|
||||||
optionsSkeleton={<MenuSkeleton />}
|
name: "My sessions",
|
||||||
isLoading={menus.user.isInitializing}
|
query: "initiator:me",
|
||||||
presets={[
|
},
|
||||||
{
|
]}
|
||||||
name: "All sessions",
|
error={error}
|
||||||
query: "",
|
options={
|
||||||
},
|
<>
|
||||||
{
|
<UserMenu menu={menus.user} placeholder="All users" />
|
||||||
name: "My sessions",
|
<ProviderFilter menu={menus.provider} />
|
||||||
query: "initiator:me",
|
{/* TODO: add client filter */}
|
||||||
},
|
{/* <ClientFilter menu={menus.client} /> */}
|
||||||
]}
|
</>
|
||||||
error={error}
|
}
|
||||||
options={
|
/>
|
||||||
<>
|
|
||||||
<UserMenu menu={menus.user} placeholder="All users" />
|
|
||||||
<ProviderFilter menu={menus.provider} />
|
|
||||||
{/* TODO: add client filter */}
|
|
||||||
{/* <ClientFilter menu={menus.client} /> */}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { paginatedSessions } from "api/queries/aiBridge";
|
||||||
|
import { useFilter } from "components/Filter/Filter";
|
||||||
|
import { useUserFilterMenu } from "components/Filter/UserFilter";
|
||||||
|
import { useAuthenticated } from "hooks";
|
||||||
|
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
|
||||||
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
|
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
import { pageTitle } from "utils/page";
|
||||||
|
import { useProviderFilterMenu } from "../RequestLogsPage/RequestLogsFilter/ProviderFilter";
|
||||||
|
import { ListSessionsPageView } from "./ListSessionsPageView";
|
||||||
|
|
||||||
|
const AISessionListPage: FC = () => {
|
||||||
|
const { permissions } = useAuthenticated();
|
||||||
|
const { entitlements } = useDashboard();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Users are allowed to view their own request logs via the API,
|
||||||
|
// but this page is only visible if the feature is enabled and the user
|
||||||
|
// has the `viewAnyAIBridgeInterception` permission.
|
||||||
|
// (as its defined in the Admin settings dropdown).
|
||||||
|
const isEntitled =
|
||||||
|
entitlements.features.aibridge.entitlement === "entitled" ||
|
||||||
|
entitlements.features.aibridge.entitlement === "grace_period";
|
||||||
|
const isEnabled = entitlements.features.aibridge.enabled;
|
||||||
|
const hasPermission = permissions.viewAnyAIBridgeInterception;
|
||||||
|
const canViewSessions = isEntitled && hasPermission;
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const sessionsQuery = usePaginatedQuery({
|
||||||
|
...paginatedSessions(searchParams),
|
||||||
|
enabled: canViewSessions,
|
||||||
|
});
|
||||||
|
const filter = useFilter({
|
||||||
|
searchParams,
|
||||||
|
onSearchParamsChange: setSearchParams,
|
||||||
|
onUpdate: sessionsQuery.goToFirstPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMenu = useUserFilterMenu({
|
||||||
|
value: filter.values.initiator,
|
||||||
|
onChange: (option) =>
|
||||||
|
filter.update({
|
||||||
|
...filter.values,
|
||||||
|
initiator: option?.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerMenu = useProviderFilterMenu({
|
||||||
|
value: filter.values.provider,
|
||||||
|
onChange: (option) =>
|
||||||
|
filter.update({
|
||||||
|
...filter.values,
|
||||||
|
provider: option?.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RequirePermission isFeatureVisible={hasPermission}>
|
||||||
|
<title>{pageTitle("Sessions", "AI Bridge")}</title>
|
||||||
|
|
||||||
|
<ListSessionsPageView
|
||||||
|
isLoading={sessionsQuery.isLoading}
|
||||||
|
isAISessionsEntitled={isEntitled}
|
||||||
|
isAISessionsEnabled={isEnabled}
|
||||||
|
sessions={sessionsQuery.data?.sessions}
|
||||||
|
sessionsQuery={sessionsQuery}
|
||||||
|
onSessionRowClick={(sessionId) =>
|
||||||
|
navigate(`/aibridge/sessions/${sessionId}`)
|
||||||
|
}
|
||||||
|
filterProps={{
|
||||||
|
filter,
|
||||||
|
error: sessionsQuery.error,
|
||||||
|
menus: {
|
||||||
|
user: userMenu,
|
||||||
|
provider: providerMenu,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AISessionListPage;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { MockSession } from "testHelpers/entities";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import {
|
||||||
|
getDefaultFilterProps,
|
||||||
|
MockMenu,
|
||||||
|
} from "components/Filter/storyHelpers";
|
||||||
|
import {
|
||||||
|
mockInitialRenderResult,
|
||||||
|
mockSuccessResult,
|
||||||
|
} from "components/PaginationWidget/PaginationContainer.mocks";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
import { ListSessionsPageView } from "./ListSessionsPageView";
|
||||||
|
|
||||||
|
type FilterProps = ComponentProps<typeof ListSessionsPageView>["filterProps"];
|
||||||
|
|
||||||
|
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
|
||||||
|
query: "owner:me",
|
||||||
|
values: {
|
||||||
|
username: undefined,
|
||||||
|
provider: undefined,
|
||||||
|
},
|
||||||
|
menus: {
|
||||||
|
user: MockMenu,
|
||||||
|
provider: MockMenu,
|
||||||
|
model: MockMenu,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta: Meta<typeof ListSessionsPageView> = {
|
||||||
|
title: "pages/AIBridgePage/ListSessionsPageView",
|
||||||
|
component: ListSessionsPageView,
|
||||||
|
args: {
|
||||||
|
isLoading: false,
|
||||||
|
isAISessionsEntitled: true,
|
||||||
|
isAISessionsEnabled: true,
|
||||||
|
filterProps: defaultFilterProps,
|
||||||
|
sessionsQuery: mockSuccessResult,
|
||||||
|
onSessionRowClick: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ListSessionsPageView>;
|
||||||
|
|
||||||
|
export const Paywall: Story = {
|
||||||
|
args: {
|
||||||
|
isAISessionsEntitled: false,
|
||||||
|
isAISessionsEnabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotEnabled: Story = {
|
||||||
|
args: {
|
||||||
|
isAISessionsEntitled: true,
|
||||||
|
isAISessionsEnabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
args: {
|
||||||
|
isLoading: true,
|
||||||
|
sessions: undefined,
|
||||||
|
sessionsQuery: mockInitialRenderResult,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: {
|
||||||
|
sessions: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loaded: Story = {
|
||||||
|
args: {
|
||||||
|
sessions: [MockSession],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleSessions: Story = {
|
||||||
|
args: {
|
||||||
|
sessions: Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
...MockSession,
|
||||||
|
id: `session-${i}`,
|
||||||
|
threads: i + 1,
|
||||||
|
last_prompt: [
|
||||||
|
"But *can* I really fix it?",
|
||||||
|
"Can you refactor the entire authentication module to use JWT tokens instead of session cookies?",
|
||||||
|
"What's the best way to handle errors in Go?",
|
||||||
|
"Help me write a Terraform module for a Kubernetes cluster.",
|
||||||
|
"Explain how the agentic loop works in this codebase.",
|
||||||
|
][i],
|
||||||
|
token_usage_summary: {
|
||||||
|
input_tokens: 1000 * (i + 1),
|
||||||
|
output_tokens: 300 * (i + 1),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { AIBridgeSession } from "api/typesGenerated";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert";
|
||||||
|
import { Link } from "components/Link/Link";
|
||||||
|
import {
|
||||||
|
PaginationContainer,
|
||||||
|
type PaginationResult,
|
||||||
|
} from "components/PaginationWidget/PaginationContainer";
|
||||||
|
import { PaywallAIGovernance } from "components/Paywall/PaywallAIGovernance";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "components/Table/Table";
|
||||||
|
import { TableEmpty } from "components/TableEmpty/TableEmpty";
|
||||||
|
import { TableLoader } from "components/TableLoader/TableLoader";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "components/Tooltip/Tooltip";
|
||||||
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps, FC, PropsWithChildren } from "react";
|
||||||
|
import { docs } from "utils/docs";
|
||||||
|
import { DATE_FORMAT, formatDateTime } from "utils/time";
|
||||||
|
import { ListSessionsFilter } from "./ListSessionsFilter";
|
||||||
|
import { ListSessionsRow } from "./ListSessionsRow";
|
||||||
|
|
||||||
|
interface ListSessionsPageViewProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
isAISessionsEntitled: boolean;
|
||||||
|
isAISessionsEnabled: boolean;
|
||||||
|
sessions?: readonly AIBridgeSession[];
|
||||||
|
sessionsQuery: PaginationResult;
|
||||||
|
filterProps: ComponentProps<typeof ListSessionsFilter>;
|
||||||
|
onSessionRowClick?: (sessionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThreadTooltip: FC<PropsWithChildren> = ({ children }) => (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex-shrink-0 flex items-center">{children}</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="end" className="max-w-xs">
|
||||||
|
<p className="text-sm">
|
||||||
|
A thread is a multi-part interaction between human and agent involving
|
||||||
|
an initial human prompt and a subsequent agentic loop.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ListSessionsPageView: FC<ListSessionsPageViewProps> = ({
|
||||||
|
isLoading,
|
||||||
|
isAISessionsEntitled,
|
||||||
|
isAISessionsEnabled,
|
||||||
|
sessions,
|
||||||
|
sessionsQuery,
|
||||||
|
filterProps,
|
||||||
|
onSessionRowClick,
|
||||||
|
}) => {
|
||||||
|
if (!isAISessionsEntitled) {
|
||||||
|
return <PaywallAIGovernance />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAISessionsEnabled) {
|
||||||
|
return (
|
||||||
|
<Alert className="mb-12" severity="warning" prominent>
|
||||||
|
<AlertTitle>
|
||||||
|
AI Bridge is included in your license, but not set up yet.
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You have access to AI Governance, but it still needs to be setup.
|
||||||
|
Check out the{" "}
|
||||||
|
<Link href={docs("/ai-coder/ai-bridge")} target="_blank">
|
||||||
|
AI Bridge
|
||||||
|
</Link>{" "}
|
||||||
|
documentation to get started.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const utcOffset = formatDateTime(new Date(), DATE_FORMAT.UTC_OFFSET);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListSessionsFilter {...filterProps} />
|
||||||
|
|
||||||
|
<PaginationContainer query={sessionsQuery} paginationUnitLabel="sessions">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-xs">
|
||||||
|
<TableHead>Last Prompt</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Provider</TableHead>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>In/Out Tokens</TableHead>
|
||||||
|
<TableHead className="flex items-center gap-1">
|
||||||
|
Threads
|
||||||
|
<ThreadTooltip>
|
||||||
|
<InfoIcon className="size-icon-xs" />
|
||||||
|
</ThreadTooltip>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Timestamp [UTC{utcOffset}]</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableLoader />
|
||||||
|
) : sessions?.length === 0 ? (
|
||||||
|
<TableEmpty message="No session logs available" />
|
||||||
|
) : (
|
||||||
|
sessions?.map((session) => (
|
||||||
|
<ListSessionsRow
|
||||||
|
session={session}
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => onSessionRowClick?.(session.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</PaginationContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { MockSession } from "testHelpers/entities";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Table, TableBody } from "components/Table/Table";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
import { ListSessionsRow } from "./ListSessionsRow";
|
||||||
|
|
||||||
|
const meta: Meta<typeof ListSessionsRow> = {
|
||||||
|
title: "pages/AIBridgePage/ListSessionsRow",
|
||||||
|
component: ListSessionsRow,
|
||||||
|
args: {
|
||||||
|
onClick: fn(),
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
<Story />
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ListSessionsRow>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
session: MockSession,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NullClient: Story = {
|
||||||
|
args: {
|
||||||
|
session: { ...MockSession, client: null },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoInitiatorName: Story = {
|
||||||
|
args: {
|
||||||
|
session: {
|
||||||
|
...MockSession,
|
||||||
|
initiator: { ...MockSession.initiator, name: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LongPrompt: Story = {
|
||||||
|
args: {
|
||||||
|
session: {
|
||||||
|
...MockSession,
|
||||||
|
last_prompt:
|
||||||
|
"Can you refactor the entire authentication module to use JWT tokens instead of session cookies, and also update all the tests, documentation, and CI pipelines while you're at it?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoPrompt: Story = {
|
||||||
|
args: {
|
||||||
|
session: { ...MockSession, last_prompt: undefined },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManyThreads: Story = {
|
||||||
|
args: {
|
||||||
|
session: { ...MockSession, threads: 128 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LargeTokenCounts: Story = {
|
||||||
|
args: {
|
||||||
|
session: {
|
||||||
|
...MockSession,
|
||||||
|
token_usage_summary: {
|
||||||
|
input_tokens: 198_000,
|
||||||
|
output_tokens: 32_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { AIBridgeSession } from "api/typesGenerated";
|
||||||
|
import { Avatar } from "components/Avatar/Avatar";
|
||||||
|
import { Badge } from "components/Badge/Badge";
|
||||||
|
import { TableCell, TableRow } from "components/Table/Table";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "components/Tooltip/Tooltip";
|
||||||
|
import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
import { AIBridgeClientIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";
|
||||||
|
import { AIBridgeProviderIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { DATE_FORMAT, formatDateTime } from "utils/time";
|
||||||
|
import {
|
||||||
|
getProviderDisplayName,
|
||||||
|
getProviderIconName,
|
||||||
|
roundTokenDisplay,
|
||||||
|
} from "../utils";
|
||||||
|
|
||||||
|
type ListSessionsRowProps = {
|
||||||
|
session: AIBridgeSession;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ListSessionsRow: FC<ListSessionsRowProps> = ({
|
||||||
|
session,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
hover
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-32 flex-1 overflow-auto">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="truncate">{session.last_prompt}</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-64">
|
||||||
|
{session.last_prompt}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-48 max-w-48">
|
||||||
|
<div className="w-full min-w-0 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Avatar
|
||||||
|
fallback={session.initiator.username}
|
||||||
|
src={session.initiator.avatar_url}
|
||||||
|
size="lg"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="font-medium truncate min-w-0 flex-1 overflow-hidden">
|
||||||
|
{session.initiator.name ?? session.initiator.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-40 max-w-40">
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge className="gap-1.5 max-w-full">
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
<AIBridgeProviderIcon
|
||||||
|
provider={getProviderIconName(session.providers[0])}
|
||||||
|
className="size-icon-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate min-w-0">
|
||||||
|
{getProviderDisplayName(session.providers[0])}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{getProviderDisplayName(session.providers[0])}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-40 max-w-40">
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge className="gap-1.5 max-w-full">
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
<AIBridgeClientIcon
|
||||||
|
client={session.client}
|
||||||
|
className="size-icon-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate min-w-0">
|
||||||
|
{session.client ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{session.client}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-32">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge className="gap-0 rounded-e-none">
|
||||||
|
<ArrowDownIcon className="size-icon-lg flex-shrink-0" />
|
||||||
|
<span className="truncate min-w-0 w-full">
|
||||||
|
{roundTokenDisplay(
|
||||||
|
session.token_usage_summary.input_tokens,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{session.token_usage_summary.input_tokens} Input Tokens
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge className="gap-0 bg-surface-tertiary rounded-s-none">
|
||||||
|
<ArrowUpIcon className="size-icon-lg flex-shrink-0" />
|
||||||
|
<span className="truncate min-w-0 w-full">
|
||||||
|
{roundTokenDisplay(
|
||||||
|
session.token_usage_summary.output_tokens,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{session.token_usage_summary.output_tokens} Output Tokens
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-32">
|
||||||
|
<Badge className="bg-surface-secondary align-end">
|
||||||
|
{session.threads}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-48 whitespace-nowrap">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{formatDateTime(
|
||||||
|
new Date(session.started_at),
|
||||||
|
DATE_FORMAT.FULL_DATETIME,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="ml-4" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -374,6 +374,14 @@ const AIBridgeRequestLogsPage = lazy(
|
|||||||
() => import("./pages/AIBridgePage/RequestLogsPage/RequestLogsPage"),
|
() => import("./pages/AIBridgePage/RequestLogsPage/RequestLogsPage"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const AIBridgeSessionsLayout = lazy(
|
||||||
|
() => import("./pages/AIBridgePage/AIBridgeSessionsLayout"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const AIBridgeListSessionsPage = lazy(
|
||||||
|
() => import("./pages/AIBridgePage/ListSessionsPage/ListSessionsPage"),
|
||||||
|
);
|
||||||
|
|
||||||
const GlobalLayout = () => {
|
const GlobalLayout = () => {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loader fullscreen />}>
|
<Suspense fallback={<Loader fullscreen />}>
|
||||||
@@ -609,6 +617,10 @@ export const router = createBrowserRouter(
|
|||||||
<Route path="request-logs" element={<AIBridgeRequestLogsPage />} />
|
<Route path="request-logs" element={<AIBridgeRequestLogsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/aibridge/sessions" element={<AIBridgeSessionsLayout />}>
|
||||||
|
<Route index element={<AIBridgeListSessionsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/health" element={<HealthLayout />}>
|
<Route path="/health" element={<HealthLayout />}>
|
||||||
<Route index element={<Navigate to="access-url" replace />} />
|
<Route index element={<Navigate to="access-url" replace />} />
|
||||||
<Route path="access-url" element={<AccessURLPage />} />
|
<Route path="access-url" element={<AccessURLPage />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user