mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 15:30:53 +02:00
small visual adjustments and chat button
This commit is contained in:
@@ -3405,6 +3405,8 @@
|
|||||||
"aiSessionNoData": "No data captured",
|
"aiSessionNoData": "No data captured",
|
||||||
"aiSessionCouldNotParse": "(raw, could not parse transcript)",
|
"aiSessionCouldNotParse": "(raw, could not parse transcript)",
|
||||||
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
|
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
|
||||||
|
"aiSessionViewRaw": "View Raw JSON",
|
||||||
|
"aiSessionViewChat": "View Chat",
|
||||||
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
|
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
|
||||||
"logRetentionRequestLabel": "HTTP Request Log Retention",
|
"logRetentionRequestLabel": "HTTP Request Log Retention",
|
||||||
"logRetentionRequestDescription": "How long to retain request logs",
|
"logRetentionRequestDescription": "How long to retain request logs",
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
|
|||||||
.pipe(z.int().positive())
|
.pipe(z.int().positive())
|
||||||
.optional(),
|
.optional(),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
isStream: z
|
isStream: z
|
||||||
.union([z.boolean(), z.string()])
|
.union([z.boolean(), z.string()])
|
||||||
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||||
@@ -123,6 +124,9 @@ function getWhere(data: Q) {
|
|||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
|
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
|
||||||
|
data.model
|
||||||
|
? eq(aiSessionLog.requestedModel, data.model)
|
||||||
|
: undefined,
|
||||||
data.isStream !== undefined
|
data.isStream !== undefined
|
||||||
? eq(aiSessionLog.isStream, data.isStream)
|
? eq(aiSessionLog.isStream, data.isStream)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -333,7 +337,8 @@ async function queryUniqueFilterAttributes(
|
|||||||
uniqueProviders,
|
uniqueProviders,
|
||||||
uniqueUsers,
|
uniqueUsers,
|
||||||
uniqueResources,
|
uniqueResources,
|
||||||
uniqueSiteResources
|
uniqueSiteResources,
|
||||||
|
uniqueModels
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
logsDb
|
logsDb
|
||||||
.selectDistinct({ id: aiSessionLog.providerId })
|
.selectDistinct({ id: aiSessionLog.providerId })
|
||||||
@@ -354,9 +359,18 @@ async function queryUniqueFilterAttributes(
|
|||||||
.selectDistinct({ id: aiSessionLog.siteResourceId })
|
.selectDistinct({ id: aiSessionLog.siteResourceId })
|
||||||
.from(aiSessionLog)
|
.from(aiSessionLog)
|
||||||
.where(and(baseConditions, isNull(aiSessionLog.resourceId)))
|
.where(and(baseConditions, isNull(aiSessionLog.resourceId)))
|
||||||
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
|
logsDb
|
||||||
|
.selectDistinct({ model: aiSessionLog.requestedModel })
|
||||||
|
.from(aiSessionLog)
|
||||||
|
.where(baseConditions)
|
||||||
.limit(DISTINCT_LIMIT + 1)
|
.limit(DISTINCT_LIMIT + 1)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const models = uniqueModels
|
||||||
|
.map((row) => row.model)
|
||||||
|
.filter((model): model is string => model !== null);
|
||||||
|
|
||||||
const providerIds = uniqueProviders
|
const providerIds = uniqueProviders
|
||||||
.map((row) => row.id)
|
.map((row) => row.id)
|
||||||
.filter((id): id is number => id !== null);
|
.filter((id): id is number => id !== null);
|
||||||
@@ -440,7 +454,8 @@ async function queryUniqueFilterAttributes(
|
|||||||
return {
|
return {
|
||||||
providers: sortNamedFilterOptions(providers),
|
providers: sortNamedFilterOptions(providers),
|
||||||
resources: sortNamedFilterOptions(resourcesWithNames),
|
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||||
users: userList
|
users: userList,
|
||||||
|
models: models.sort()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export type QueryAiSessionLogResponse = {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
models: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,12 +50,14 @@ export default function AiSessionLogsPage() {
|
|||||||
capability?: string;
|
capability?: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
|
model?: string;
|
||||||
isStream?: string;
|
isStream?: string;
|
||||||
}>({
|
}>({
|
||||||
providerId: searchParams.get("providerId") || undefined,
|
providerId: searchParams.get("providerId") || undefined,
|
||||||
capability: searchParams.get("capability") || undefined,
|
capability: searchParams.get("capability") || undefined,
|
||||||
resourceId: searchParams.get("resourceId") || undefined,
|
resourceId: searchParams.get("resourceId") || undefined,
|
||||||
actor: searchParams.get("actor") || undefined,
|
actor: searchParams.get("actor") || undefined,
|
||||||
|
model: searchParams.get("model") || undefined,
|
||||||
isStream: searchParams.get("isStream") || undefined
|
isStream: searchParams.get("isStream") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,7 +134,8 @@ export default function AiSessionLogsPage() {
|
|||||||
const filterAttributes = data?.filterAttributes ?? {
|
const filterAttributes = data?.filterAttributes ?? {
|
||||||
providers: [],
|
providers: [],
|
||||||
resources: [],
|
resources: [],
|
||||||
users: []
|
users: [],
|
||||||
|
models: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
@@ -309,15 +312,27 @@ export default function AiSessionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "requestedModel",
|
accessorKey: "requestedModel",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => {
|
||||||
<span className="px-2">{t("model")}</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return (
|
return (
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 px-2">
|
||||||
{row.original.requestedModel || "-"}
|
<ColumnFilterButton
|
||||||
</span>
|
options={filterAttributes.models.map((model) => ({
|
||||||
|
value: model,
|
||||||
|
label: model
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.model}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("model", value)
|
||||||
|
}
|
||||||
|
label={t("model")}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <span>{row.original.requestedModel || "-"}</span>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -444,7 +459,7 @@ export default function AiSessionLogsPage() {
|
|||||||
const renderExpandedRow = (row: any) => {
|
const renderExpandedRow = (row: any) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs">
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("aiSessionId")}</strong>
|
<strong>{t("aiSessionId")}</strong>
|
||||||
<p className="text-muted-foreground mt-1 break-all">
|
<p className="text-muted-foreground mt-1 break-all">
|
||||||
@@ -457,18 +472,6 @@ export default function AiSessionLogsPage() {
|
|||||||
{row.statusCode ?? "N/A"}
|
{row.statusCode ?? "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<AiSessionChatView
|
<AiSessionChatView
|
||||||
normalizedRequest={row.normalizedRequest}
|
normalizedRequest={row.normalizedRequest}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { AlertTriangle, Bot, Terminal, User as UserIcon, Wrench } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Bot,
|
||||||
|
Code,
|
||||||
|
MessagesSquare,
|
||||||
|
Terminal,
|
||||||
|
User as UserIcon,
|
||||||
|
Wrench
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization";
|
import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization";
|
||||||
|
|
||||||
type AiSessionChatViewProps = {
|
type AiSessionChatViewProps = {
|
||||||
@@ -95,14 +104,14 @@ function RawFallbackBlock({
|
|||||||
label: string;
|
label: string;
|
||||||
raw: string | null;
|
raw: string | null;
|
||||||
noDataLabel: string;
|
noDataLabel: string;
|
||||||
unparsedLabel: string;
|
unparsedLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const pretty = prettyRaw(raw);
|
const pretty = prettyRaw(raw);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/30 p-3">
|
<div className="rounded-md border bg-muted/30 p-3">
|
||||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
{pretty && (
|
{pretty && unparsedLabel && (
|
||||||
<span className="ml-2 font-normal italic opacity-70">
|
<span className="ml-2 font-normal italic opacity-70">
|
||||||
{unparsedLabel}
|
{unparsedLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -123,6 +132,7 @@ export function AiSessionChatView({
|
|||||||
truncated
|
truncated
|
||||||
}: AiSessionChatViewProps) {
|
}: AiSessionChatViewProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const [rawMode, setRawMode] = useState(false);
|
||||||
|
|
||||||
const requestMessages = useMemo(
|
const requestMessages = useMemo(
|
||||||
() => parseMessages(normalizedRequest),
|
() => parseMessages(normalizedRequest),
|
||||||
@@ -139,38 +149,69 @@ export function AiSessionChatView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{truncated && (
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500">
|
{truncated ? (
|
||||||
<AlertTriangle className="h-3.5 w-3.5 flex-none" />
|
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500">
|
||||||
{t("aiSessionLogTruncated")}
|
<AlertTriangle className="h-3.5 w-3.5 flex-none" />
|
||||||
</div>
|
{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} />
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRawMode((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{rawMode ? (
|
||||||
|
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Code className="mr-2 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{rawMode ? t("aiSessionViewChat") : t("aiSessionViewRaw")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rawMode ? (
|
||||||
|
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||||
<RawFallbackBlock
|
<RawFallbackBlock
|
||||||
label={t("aiSessionRequest")}
|
label={t("aiSessionRequest")}
|
||||||
raw={requestBody}
|
raw={normalizedRequest}
|
||||||
noDataLabel={t("aiSessionNoData")}
|
noDataLabel={t("aiSessionNoData")}
|
||||||
unparsedLabel={t("aiSessionCouldNotParse")}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{hasResponseMessages ? (
|
|
||||||
responseMessages!.map((message, i) => (
|
|
||||||
<MessageBubble key={`res-${i}`} message={message} />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<RawFallbackBlock
|
<RawFallbackBlock
|
||||||
label={t("aiSessionResponse")}
|
label={t("aiSessionResponse")}
|
||||||
raw={responseBody}
|
raw={normalizedResponse}
|
||||||
noDataLabel={t("aiSessionNoData")}
|
noDataLabel={t("aiSessionNoData")}
|
||||||
unparsedLabel={t("aiSessionCouldNotParse")}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1053,6 +1053,7 @@ export const aiSessionLogsFiltersSchema = z.object({
|
|||||||
capability: z.string().optional().catch(undefined),
|
capability: z.string().optional().catch(undefined),
|
||||||
resourceId: z.string().optional().catch(undefined),
|
resourceId: z.string().optional().catch(undefined),
|
||||||
actor: z.string().optional().catch(undefined),
|
actor: z.string().optional().catch(undefined),
|
||||||
|
model: z.string().optional().catch(undefined),
|
||||||
isStream: z.string().optional().catch(undefined)
|
isStream: z.string().optional().catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user