mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 08:19:51 +02:00
show the sessions in the ui
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Session Logs"
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
"use client";
|
||||
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { AiSessionChatView } from "@app/components/AiSessionChatView";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { ArrowUpRight, Bot, Waves, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import type { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
|
||||
|
||||
const capabilityLabels: Record<string, string> = {
|
||||
openai_chat: "OpenAI Chat Completions",
|
||||
openai_responses: "OpenAI Responses",
|
||||
anthropic_messages: "Anthropic Messages",
|
||||
gemini_generate_content: "Gemini",
|
||||
google_generate_content: "Vertex AI (Generate Content)",
|
||||
google_raw_predict: "Vertex AI (Raw Predict)",
|
||||
bedrock_model_invoke: "Bedrock (Invoke Model)",
|
||||
bedrock_converse: "Bedrock (Converse)"
|
||||
};
|
||||
|
||||
export default function AiSessionLogsPage() {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [pageSize, setPageSize] = useStoredPageSize("ai-session-logs", 20);
|
||||
|
||||
const [filters, setFilters] = useState<{
|
||||
providerId?: string;
|
||||
capability?: string;
|
||||
resourceId?: string;
|
||||
actor?: string;
|
||||
isStream?: string;
|
||||
}>({
|
||||
providerId: searchParams.get("providerId") || undefined,
|
||||
capability: searchParams.get("capability") || undefined,
|
||||
resourceId: searchParams.get("resourceId") || undefined,
|
||||
actor: searchParams.get("actor") || undefined,
|
||||
isStream: searchParams.get("isStream") || undefined
|
||||
});
|
||||
|
||||
const getDefaultDateRange = () => {
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
};
|
||||
}
|
||||
return {
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
if (dateRange.startDate?.date) {
|
||||
const dt = new Date(dateRange.startDate.date);
|
||||
if (dateRange.startDate.time) {
|
||||
const [h, m, s] = dateRange.startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
}
|
||||
timeStart = dt.toISOString();
|
||||
}
|
||||
|
||||
if (dateRange.endDate?.date) {
|
||||
const dt = new Date(dateRange.endDate.date);
|
||||
if (dateRange.endDate.time) {
|
||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
} else {
|
||||
const now = new Date();
|
||||
dt.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
timeEnd = dt.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
...filters
|
||||
};
|
||||
}, [dateRange, currentPage, pageSize, filters]);
|
||||
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
...logQueries.aiSessions({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
})
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
|
||||
const totalCount = data?.pagination?.total ?? 0;
|
||||
const filterAttributes = data?.filterAttributes ?? {
|
||||
providers: [],
|
||||
resources: [],
|
||||
users: []
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0);
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0);
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
newFilters:
|
||||
| typeof filters
|
||||
| {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(newFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
: undefined,
|
||||
timeEnd: dateRange.endDate?.date
|
||||
? new Date(dateRange.endDate.date).toISOString()
|
||||
: undefined,
|
||||
...filters
|
||||
};
|
||||
|
||||
const response = await api.get(`/org/${orgId}/logs/ai/export`, {
|
||||
responseType: "blob",
|
||||
params
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`ai-session-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
} catch (error) {
|
||||
let apiErrorMessage: string | null = null;
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: apiErrorMessage ?? t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<span className="px-2">{t("timestamp")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(row.original.createdAt).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "providerName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.providers.map(
|
||||
(provider) => ({
|
||||
value: provider.id.toString(),
|
||||
label: provider.name || "Unnamed Provider"
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.providerId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("providerId", value)
|
||||
}
|
||||
label={t("provider")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<Bot className="h-4 w-4" />
|
||||
{row.original.providerName || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "capability",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={Object.entries(capabilityLabels).map(
|
||||
([value, label]) => ({ value, label })
|
||||
)}
|
||||
selectedValue={filters.capability}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("capability", value)
|
||||
}
|
||||
label={t("capability")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{capabilityLabels[row.original.capability] ||
|
||||
row.original.capability}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "requestedModel",
|
||||
header: ({ column }) => (
|
||||
<span className="px-2">{t("model")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.requestedModel || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.resources.map((res) => ({
|
||||
value: res.id.toString(),
|
||||
label: res.name || "Unnamed Resource"
|
||||
}))}
|
||||
selectedValue={filters.resourceId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
label={t("resource")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.resourceNiceId) {
|
||||
return <span className="text-xs text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={
|
||||
row.original.resourceType === "site"
|
||||
? getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)
|
||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "isStream",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("streaming") },
|
||||
{ value: "false", label: t("nonStreaming") }
|
||||
]}
|
||||
label={t("stream")}
|
||||
selectedValue={filters.isStream}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("isStream", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.isStream ? (
|
||||
<>
|
||||
<Waves className="h-4 w-4" />
|
||||
{t("streaming")}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("nonStreaming")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userEmail",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.users.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.email || user.id
|
||||
}))}
|
||||
selectedValue={filters.actor}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
label={t("actor")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.userEmail ? (
|
||||
<>
|
||||
<User className="h-4 w-4" />
|
||||
{row.original.userEmail}
|
||||
</>
|
||||
) : (
|
||||
<>-</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs">
|
||||
<div>
|
||||
<strong>{t("aiSessionId")}</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.sessionId}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("statusCode")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.statusCode ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("model")}</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.requestedModel || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("capability")}</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{capabilityLabels[row.capability] || row.capability}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AiSessionChatView
|
||||
normalizedRequest={row.normalizedRequest}
|
||||
normalizedResponse={row.normalizedResponse}
|
||||
requestBody={row.requestBody}
|
||||
responseBody={row.responseBody}
|
||||
truncated={row.truncated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("aiSessionLogs")}
|
||||
description={t("aiSessionLogsDescription")}
|
||||
/>
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("aiSessionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="providerName"
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "createdAt",
|
||||
desc: true
|
||||
}}
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
pageSize={pageSize}
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
|
||||
const capabilities = Object.keys(capabilityLabels);
|
||||
const providers = [
|
||||
{ id: 1, name: "OpenAI Production" },
|
||||
{ id: 2, name: "Anthropic Default" },
|
||||
{ id: 3, name: "Vertex AI" }
|
||||
];
|
||||
const resourcesSample = [
|
||||
{ id: 1, niceId: "resource-1", name: "Resource 1" },
|
||||
{ id: 2, niceId: "resource-2", name: "Resource 2" }
|
||||
];
|
||||
const actors = ["alice@example.com", "bob@example.com", null];
|
||||
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
|
||||
|
||||
const now = Date.now();
|
||||
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const provider = providers[Math.floor(Math.random() * providers.length)];
|
||||
const resource =
|
||||
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
|
||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||
|
||||
return {
|
||||
id: i,
|
||||
sessionId: `sample-session-${i}`,
|
||||
orgId: "sample-org",
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
providerType: "openai",
|
||||
capability:
|
||||
capabilities[Math.floor(Math.random() * capabilities.length)],
|
||||
resourceId: resource.id,
|
||||
siteResourceId: null,
|
||||
resourceName: resource.name,
|
||||
resourceNiceId: resource.niceId,
|
||||
resourceType: "public",
|
||||
userId: actor ? `user-${i}` : null,
|
||||
userEmail: actor,
|
||||
requestedModel: models[Math.floor(Math.random() * models.length)],
|
||||
isStream: Math.random() > 0.5,
|
||||
requestBody: null,
|
||||
responseBody: null,
|
||||
normalizedRequest: null,
|
||||
normalizedResponse: null,
|
||||
truncated: false,
|
||||
statusCode: 200,
|
||||
createdAt: Math.floor(
|
||||
sevenDaysAgoMs + Math.random() * (now - sevenDaysAgoMs)
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Env } from "@app/lib/types/env";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
BellRing,
|
||||
Bot,
|
||||
Boxes,
|
||||
Building2,
|
||||
Cable,
|
||||
@@ -227,6 +228,11 @@ export const orgNavSections = (
|
||||
<SquareMousePointer className="size-4 flex-none" />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAi",
|
||||
href: "/{orgId}/settings/logs/ai",
|
||||
icon: <Bot className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
@@ -521,6 +527,11 @@ export const commandBarNavSections = (
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandLogsAi",
|
||||
href: "/{orgId}/settings/logs/ai",
|
||||
icon: <Bot className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertTriangle, Bot, Terminal, User as UserIcon, Wrench } from "lucide-react";
|
||||
import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization";
|
||||
|
||||
type AiSessionChatViewProps = {
|
||||
normalizedRequest: string | null;
|
||||
normalizedResponse: string | null;
|
||||
requestBody: string | null;
|
||||
responseBody: string | null;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function parseMessages(json: string | null): NormalizedAiMessage[] | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
return Array.isArray(parsed) ? (parsed as NormalizedAiMessage[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function prettyRaw(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: NormalizedAiMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
const isSystem = message.role === "system";
|
||||
const isTool = message.role === "tool";
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Terminal className="h-3.5 w-3.5 mt-0.5 flex-none" />
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
{message.content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-2 ${isUser ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-7 w-7 flex-none items-center justify-center rounded-full ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isTool
|
||||
? "bg-amber-100 dark:bg-amber-900/40"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<UserIcon className="h-4 w-4" />
|
||||
) : isTool ? (
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isTool
|
||||
? "bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 font-mono text-xs"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{message.content || (
|
||||
<span className="italic opacity-60"> </span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RawFallbackBlock({
|
||||
label,
|
||||
raw,
|
||||
noDataLabel,
|
||||
unparsedLabel
|
||||
}: {
|
||||
label: string;
|
||||
raw: string | null;
|
||||
noDataLabel: string;
|
||||
unparsedLabel: string;
|
||||
}) {
|
||||
const pretty = prettyRaw(raw);
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
{pretty && (
|
||||
<span className="ml-2 font-normal italic opacity-70">
|
||||
{unparsedLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{pretty ?? noDataLabel}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiSessionChatView({
|
||||
normalizedRequest,
|
||||
normalizedResponse,
|
||||
requestBody,
|
||||
responseBody,
|
||||
truncated
|
||||
}: AiSessionChatViewProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const requestMessages = useMemo(
|
||||
() => parseMessages(normalizedRequest),
|
||||
[normalizedRequest]
|
||||
);
|
||||
const responseMessages = useMemo(
|
||||
() => parseMessages(normalizedResponse),
|
||||
[normalizedResponse]
|
||||
);
|
||||
|
||||
const hasRequestMessages = !!requestMessages && requestMessages.length > 0;
|
||||
const hasResponseMessages =
|
||||
!!responseMessages && responseMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{truncated && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-none" />
|
||||
{t("aiSessionLogTruncated")}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
{hasRequestMessages ? (
|
||||
requestMessages!.map((message, i) => (
|
||||
<MessageBubble key={`req-${i}`} message={message} />
|
||||
))
|
||||
) : (
|
||||
<RawFallbackBlock
|
||||
label={t("aiSessionRequest")}
|
||||
raw={requestBody}
|
||||
noDataLabel={t("aiSessionNoData")}
|
||||
unparsedLabel={t("aiSessionCouldNotParse")}
|
||||
/>
|
||||
)}
|
||||
{hasResponseMessages ? (
|
||||
responseMessages!.map((message, i) => (
|
||||
<MessageBubble key={`res-${i}`} message={message} />
|
||||
))
|
||||
) : (
|
||||
<RawFallbackBlock
|
||||
label={t("aiSessionResponse")}
|
||||
raw={responseBody}
|
||||
noDataLabel={t("aiSessionNoData")}
|
||||
unparsedLabel={t("aiSessionCouldNotParse")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||
import type {
|
||||
QueryAccessAuditLogResponse,
|
||||
QueryActionAuditLogResponse,
|
||||
QueryAiSessionLogResponse,
|
||||
QueryConnectionAuditLogResponse,
|
||||
QueryRequestAuditLogResponse
|
||||
} from "@server/routers/auditLogs/types";
|
||||
@@ -1031,6 +1032,32 @@ export const connectionLogsFiltersSchema = z.object({
|
||||
|
||||
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
|
||||
|
||||
export const aiSessionLogsFiltersSchema = z.object({
|
||||
timeStart: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
timeEnd: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
page: z.coerce.number().optional().catch(0).default(0),
|
||||
pageSize: z.coerce.number().optional().catch(20).default(20),
|
||||
providerId: z.string().optional().catch(undefined),
|
||||
capability: z.string().optional().catch(undefined),
|
||||
resourceId: z.string().optional().catch(undefined),
|
||||
actor: z.string().optional().catch(undefined),
|
||||
isStream: z.string().optional().catch(undefined)
|
||||
});
|
||||
|
||||
export type AiSessionLogFilters = z.output<typeof aiSessionLogsFiltersSchema>;
|
||||
|
||||
export const logQueries = {
|
||||
requestAnalytics: ({
|
||||
orgId,
|
||||
@@ -1180,6 +1207,37 @@ export const logQueries = {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
|
||||
aiSessions: ({
|
||||
orgId,
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: AiSessionLogFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_SESSION_LOGS", orgId, "ALL", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const { page, pageSize, ...rest } = filters;
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiSessionLogResponse>
|
||||
>(`/org/${orgId}/logs/ai`, {
|
||||
params: {
|
||||
...rest,
|
||||
limit: pageSize,
|
||||
offset: page * pageSize
|
||||
},
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data) {
|
||||
return durationToMs(30, "seconds");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user