first commit
This commit is contained in:
167
ui/app/workspace/logs/views/audioPlayer.tsx
Normal file
167
ui/app/workspace/logs/views/audioPlayer.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pause, Play, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
format?: string; // Optional format: "mp3", "wav", "pcm16", etc.
|
||||
}
|
||||
|
||||
const AudioPlayer = ({ src, format }: AudioPlayerProps) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [audio] = useState<HTMLAudioElement | null>(typeof window !== "undefined" ? new Audio() : null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Convert PCM16 to WAV format
|
||||
const convertPCM16ToWAV = (pcmData: Uint8Array, sampleRate: number = 24000, numChannels: number = 1): Uint8Array => {
|
||||
const bitsPerSample = 16;
|
||||
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
||||
const blockAlign = (numChannels * bitsPerSample) / 8;
|
||||
const dataSize = pcmData.length;
|
||||
const fileSize = 36 + dataSize;
|
||||
|
||||
const wavBuffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(wavBuffer);
|
||||
|
||||
// RIFF header
|
||||
const writeString = (offset: number, string: string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, "RIFF");
|
||||
view.setUint32(4, fileSize, true);
|
||||
writeString(8, "WAVE");
|
||||
|
||||
// fmt subchunk
|
||||
writeString(12, "fmt ");
|
||||
view.setUint32(16, 16, true); // Subchunk1Size
|
||||
view.setUint16(20, 1, true); // AudioFormat (1 = PCM)
|
||||
view.setUint16(22, numChannels, true); // NumChannels
|
||||
view.setUint32(24, sampleRate, true); // SampleRate
|
||||
view.setUint32(28, byteRate, true); // ByteRate
|
||||
view.setUint16(32, blockAlign, true); // BlockAlign
|
||||
view.setUint16(34, bitsPerSample, true); // BitsPerSample
|
||||
|
||||
// data subchunk
|
||||
writeString(36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// Copy PCM data
|
||||
const wavArray = new Uint8Array(wavBuffer);
|
||||
wavArray.set(pcmData, 44);
|
||||
|
||||
return wavArray;
|
||||
};
|
||||
|
||||
const createAudioBlob = (base64Data: string, audioFormat?: string): Blob | null => {
|
||||
try {
|
||||
const binaryString = atob(base64Data);
|
||||
const pcmData = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
|
||||
|
||||
// Handle PCM16 format - convert to WAV
|
||||
if (audioFormat === "pcm16" || audioFormat === "pcm_s16le_16") {
|
||||
const wavData = convertPCM16ToWAV(pcmData);
|
||||
// Create a new ArrayBuffer to ensure proper type
|
||||
const buffer = new ArrayBuffer(wavData.length);
|
||||
new Uint8Array(buffer).set(wavData);
|
||||
return new Blob([buffer], {
|
||||
type: "audio/wav",
|
||||
});
|
||||
}
|
||||
|
||||
// Handle other formats
|
||||
let mimeType = "audio/mpeg"; // Default to MP3
|
||||
if (audioFormat === "wav") {
|
||||
mimeType = "audio/wav";
|
||||
} else if (audioFormat === "ogg") {
|
||||
mimeType = "audio/ogg";
|
||||
} else if (audioFormat === "webm") {
|
||||
mimeType = "audio/webm";
|
||||
}
|
||||
|
||||
return new Blob([pcmData], {
|
||||
type: mimeType,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to decode audio data:", err);
|
||||
setError("Failed to decode audio data. The audio file may be corrupted.");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audio || !src) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
const audioBlob = createAudioBlob(src, format);
|
||||
if (!audioBlob) return;
|
||||
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
audio.src = audioUrl;
|
||||
audio.play().catch((err) => {
|
||||
console.error("Failed to play audio:", err);
|
||||
setError("Failed to play audio. Please try again.");
|
||||
setIsPlaying(false);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false);
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!src) return;
|
||||
|
||||
const audioBlob = createAudioBlob(src, format);
|
||||
if (!audioBlob) return;
|
||||
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Determine file extension based on format
|
||||
let extension = "mp3";
|
||||
if (format === "pcm16" || format === "pcm_s16le_16") {
|
||||
extension = "wav";
|
||||
} else if (format === "wav") {
|
||||
extension = "wav";
|
||||
} else if (format === "ogg") {
|
||||
extension = "ogg";
|
||||
} else if (format === "webm") {
|
||||
extension = "webm";
|
||||
}
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = audioUrl;
|
||||
a.download = `speech-output.${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handlePlayPause} variant="outline" size="sm" className="flex items-center gap-2" disabled={!!error}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? "Pause" : "Play"}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleDownload} variant="outline" size="sm" className="flex items-center gap-2" disabled={!!error}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-500">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPlayer;
|
||||
8
ui/app/workspace/logs/views/blockHeader.tsx
Normal file
8
ui/app/workspace/logs/views/blockHeader.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function BlockHeader({ title, icon }: { title: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
ui/app/workspace/logs/views/codeEditor.css
Normal file
16
ui/app/workspace/logs/views/codeEditor.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.monaco-editor-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.overflow-guard {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.monaco-editor {
|
||||
outline-style: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.margin {
|
||||
background: transparent !important;
|
||||
}
|
||||
87
ui/app/workspace/logs/views/collapsibleBox.tsx
Normal file
87
ui/app/workspace/logs/views/collapsibleBox.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface CollapsibleBoxProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
collapsedHeight?: number;
|
||||
expandedMaxHeight?: number;
|
||||
onCopy?: () => string;
|
||||
}
|
||||
|
||||
export default function CollapsibleBox({ title, children, collapsedHeight = 60, expandedMaxHeight = 450, onCopy }: CollapsibleBoxProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [needsExpansion, setNeedsExpansion] = useState(false);
|
||||
const innerContentRef = useRef<HTMLDivElement>(null);
|
||||
const { copy } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (!innerContentRef.current) return;
|
||||
|
||||
const checkHeight = () => {
|
||||
const scrollHeight = innerContentRef.current?.scrollHeight || 0;
|
||||
setNeedsExpansion(scrollHeight > collapsedHeight);
|
||||
};
|
||||
|
||||
// Initial check after a small delay to allow content to render
|
||||
const timeoutId = setTimeout(checkHeight, 50);
|
||||
|
||||
// Observe for resize changes
|
||||
const observer = new ResizeObserver(checkHeight);
|
||||
observer.observe(innerContentRef.current);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [children, collapsedHeight]);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!onCopy) return;
|
||||
copy(onCopy());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center justify-between border-b py-2 pl-6">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
{onCopy && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground mx-2 h-6 py-1 hover:bg-transparent hover:text-black dark:hover:text-white"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-200"
|
||||
style={{ maxHeight: isExpanded ? `${expandedMaxHeight}px` : `${collapsedHeight}px` }}
|
||||
>
|
||||
<div ref={innerContentRef}>{children}</div>
|
||||
</div>
|
||||
{needsExpansion && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="bg-muted/50 text-muted-foreground hover:bg-muted flex w-full items-center justify-center gap-1 border-t py-1 text-xs"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
show more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
ui/app/workspace/logs/views/columns.test.ts
Normal file
100
ui/app/workspace/logs/views/columns.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { LogEntry } from "@/lib/types/logs";
|
||||
|
||||
import { getMessage } from "./columns";
|
||||
|
||||
describe("getMessage", () => {
|
||||
it("returns EI realtime text from input history", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello from the browser" }],
|
||||
},
|
||||
],
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe("User: hello from the browser");
|
||||
});
|
||||
|
||||
it("returns LM realtime text from output message", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [],
|
||||
responses_input_history: [],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello from the model" }],
|
||||
},
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe("Assistant: hello from the model");
|
||||
});
|
||||
|
||||
it("returns split realtime text when both user and assistant are present", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "who are you?" }],
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I am the assistant." }],
|
||||
},
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe("User: who are you?\nAssistant: I am the assistant.");
|
||||
});
|
||||
|
||||
it("returns split realtime text including tool output", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [
|
||||
{
|
||||
role: "tool",
|
||||
content: [{ type: "text", text: '{"nextResponse":"tool result"}' }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "who are you?" }],
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I am the assistant." }],
|
||||
},
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe('Tool Result: {"nextResponse":"tool result"}\nUser: who are you?\nAssistant: I am the assistant.');
|
||||
});
|
||||
|
||||
it("returns realtime assistant tool calls from output message", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "show me a pastel palette" }],
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "display_color_palette",
|
||||
arguments: '{"theme":"pastel"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe('User: show me a pastel palette\nAssistant Tool Call: display_color_palette({"theme":"pastel"})');
|
||||
});
|
||||
});
|
||||
499
ui/app/workspace/logs/views/columns.tsx
Normal file
499
ui/app/workspace/logs/views/columns.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import {
|
||||
formatCost,
|
||||
formatLatency,
|
||||
formatTokens,
|
||||
} from "@/app/workspace/dashboard/utils/chartUtils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import {
|
||||
getProviderLabel,
|
||||
ProviderName,
|
||||
RequestTypeColors,
|
||||
RequestTypeLabels,
|
||||
Status,
|
||||
StatusBarColors,
|
||||
} from "@/lib/constants/logs";
|
||||
import {
|
||||
ChatMessageContent,
|
||||
LogEntry,
|
||||
ResponsesMessageContentBlock,
|
||||
} from "@/lib/types/logs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { ArrowUpDown, Trash2 } from "lucide-react";
|
||||
|
||||
function getAssistantToolCallSummary(log?: LogEntry): string {
|
||||
const toolCalls = log?.output_message?.tool_calls || [];
|
||||
return toolCalls
|
||||
.map((toolCall) => {
|
||||
const name = toolCall?.function?.name;
|
||||
if (!name) {
|
||||
return "";
|
||||
}
|
||||
const argumentsText = toolCall?.function?.arguments?.trim();
|
||||
return argumentsText ? `${name}(${argumentsText})` : name;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function getMessageFromContent(content?: ChatMessageContent): string {
|
||||
if (content == undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
let lastTextContentBlock = "";
|
||||
for (const block of content) {
|
||||
if (
|
||||
(block.type === "text" ||
|
||||
block.type === "input_text" ||
|
||||
block.type === "output_text") &&
|
||||
block.text
|
||||
) {
|
||||
lastTextContentBlock = block.text;
|
||||
}
|
||||
}
|
||||
return lastTextContentBlock;
|
||||
}
|
||||
|
||||
export function getRealtimeTurnMessages(log?: LogEntry): {
|
||||
tool?: string;
|
||||
user?: string;
|
||||
assistant?: string;
|
||||
assistantToolCall?: string;
|
||||
} {
|
||||
const toolMessages =
|
||||
log?.input_history?.filter((message) => message.role === "tool") || [];
|
||||
const userMessages =
|
||||
log?.input_history?.filter((message) => message.role === "user") || [];
|
||||
return {
|
||||
tool:
|
||||
toolMessages
|
||||
.map((m) => getMessageFromContent(m.content))
|
||||
.filter(Boolean)
|
||||
.join("\n") || "",
|
||||
user:
|
||||
userMessages
|
||||
.map((m) => getMessageFromContent(m.content))
|
||||
.filter(Boolean)
|
||||
.join("\n") || "",
|
||||
assistant: log?.output_message
|
||||
? getMessageFromContent(log.output_message.content)
|
||||
: "",
|
||||
assistantToolCall: getAssistantToolCallSummary(log),
|
||||
};
|
||||
}
|
||||
|
||||
export function getMessage(log?: LogEntry) {
|
||||
if (log?.object === "list_models") {
|
||||
return "N/A";
|
||||
}
|
||||
if (log?.object === "realtime.turn") {
|
||||
const messages = getRealtimeTurnMessages(log);
|
||||
const parts = [
|
||||
messages.tool ? `Tool Result: ${messages.tool}` : "",
|
||||
messages.user ? `User: ${messages.user}` : "",
|
||||
messages.assistantToolCall
|
||||
? `Assistant Tool Call: ${messages.assistantToolCall}`
|
||||
: "",
|
||||
messages.assistant ? `Assistant: ${messages.assistant}` : "",
|
||||
].filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (log?.input_history && log.input_history.length > 0) {
|
||||
return getMessageFromContent(
|
||||
log.input_history[log.input_history.length - 1].content,
|
||||
);
|
||||
} else if (
|
||||
log?.responses_input_history &&
|
||||
log.responses_input_history.length > 0
|
||||
) {
|
||||
let lastMessage =
|
||||
log.responses_input_history[log.responses_input_history.length - 1];
|
||||
let lastMessageContent = lastMessage.content;
|
||||
if (typeof lastMessageContent === "string") {
|
||||
return lastMessageContent;
|
||||
}
|
||||
let lastTextContentBlock = "";
|
||||
for (const block of (lastMessageContent ??
|
||||
[]) as ResponsesMessageContentBlock[]) {
|
||||
if (block.text && block.text !== "") {
|
||||
lastTextContentBlock = block.text;
|
||||
}
|
||||
}
|
||||
// If no content found in content field, check output field for Responses API
|
||||
if (!lastTextContentBlock && lastMessage.output) {
|
||||
// Handle output field - it could be a string, an array of content blocks, or a computer tool call output data
|
||||
if (typeof lastMessage.output === "string") {
|
||||
return lastMessage.output;
|
||||
} else if (Array.isArray(lastMessage.output)) {
|
||||
return lastMessage.output.map((block) => block.text).join("\n");
|
||||
} else if (
|
||||
lastMessage.output.type &&
|
||||
lastMessage.output.type === "computer_screenshot"
|
||||
) {
|
||||
return lastMessage.output.image_url;
|
||||
}
|
||||
}
|
||||
return lastTextContentBlock ?? "";
|
||||
} else if (log?.output_message) {
|
||||
return getMessageFromContent(log.output_message.content);
|
||||
} else if (log?.speech_input) {
|
||||
return log.speech_input.input;
|
||||
} else if (log?.transcription_input) {
|
||||
return "Audio file";
|
||||
} else if (log?.image_generation_input?.prompt) {
|
||||
return log.image_generation_input.prompt;
|
||||
}
|
||||
const obj = log?.object as string | undefined;
|
||||
if (
|
||||
obj === "image_edit" ||
|
||||
obj === "image_edit_stream" ||
|
||||
obj === "image_variation"
|
||||
) {
|
||||
return "Image file";
|
||||
}
|
||||
if (log?.content_summary) {
|
||||
return log.content_summary;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function LogMessageCell({
|
||||
log,
|
||||
maxWidth = "max-w-[400px]",
|
||||
}: {
|
||||
log: LogEntry;
|
||||
maxWidth?: string;
|
||||
}) {
|
||||
const input = getMessage(log);
|
||||
const isLargePayload =
|
||||
log.is_large_payload_request || log.is_large_payload_response;
|
||||
const realtimeMessages =
|
||||
log.object === "realtime.turn" ? getRealtimeTurnMessages(log) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isLargePayload && (
|
||||
<span
|
||||
className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400"
|
||||
title="Large payload - streamed directly to provider"
|
||||
>
|
||||
LP
|
||||
</span>
|
||||
)}
|
||||
{realtimeMessages &&
|
||||
(realtimeMessages.tool ||
|
||||
realtimeMessages.user ||
|
||||
realtimeMessages.assistantToolCall ||
|
||||
realtimeMessages.assistant) ? (
|
||||
<div
|
||||
className={cn(maxWidth, "font-mono text-sm font-normal leading-5")}
|
||||
title={input || "-"}
|
||||
>
|
||||
{realtimeMessages.tool ? (
|
||||
<div className="truncate">Tool Result: {realtimeMessages.tool}</div>
|
||||
) : null}
|
||||
{realtimeMessages.user ? (
|
||||
<div className="truncate">User: {realtimeMessages.user}</div>
|
||||
) : null}
|
||||
{realtimeMessages.assistantToolCall ? (
|
||||
<div className="truncate">
|
||||
Assistant Tool Call: {realtimeMessages.assistantToolCall}
|
||||
</div>
|
||||
) : null}
|
||||
{realtimeMessages.assistant ? (
|
||||
<div className="truncate">
|
||||
Assistant: {realtimeMessages.assistant}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(maxWidth, "truncate font-mono text-[12px] font-normal")}
|
||||
title={input || "-"}
|
||||
>
|
||||
{input ||
|
||||
(isLargePayload
|
||||
? `Large payload ${log.is_large_payload_request && log.is_large_payload_response ? "request & response" : log.is_large_payload_request ? "request" : "response"}`
|
||||
: "-")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const createColumns = (
|
||||
onDelete: (log: LogEntry) => void,
|
||||
hasDeleteAccess = true,
|
||||
metadataKeys: string[] = [],
|
||||
): ColumnDef<LogEntry>[] => {
|
||||
const baseColumns: ColumnDef<LogEntry>[] = [
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "",
|
||||
size: 8,
|
||||
maxSize: 8,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status as Status;
|
||||
return (
|
||||
<div
|
||||
className={`h-full min-h-[24px] w-1 rounded-sm ${StatusBarColors[status]}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="logs-time-sort-btn"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Time
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
size: 130,
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.original.timestamp;
|
||||
const date = timestamp ? new Date(timestamp) : null;
|
||||
const isValid = date && date.toString() !== "Invalid Date";
|
||||
if (!isValid) {
|
||||
return <div className="truncate text-xs">N/A</div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{format(date, "MMM dd HH:mm:ss")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10.5px] tabular-nums">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "request_type",
|
||||
header: "Type",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"font-mono text-[11px] py-0.5 px-1.5 uppercase",
|
||||
RequestTypeColors[
|
||||
row.original.object as keyof typeof RequestTypeColors
|
||||
],
|
||||
)}
|
||||
>
|
||||
{
|
||||
RequestTypeLabels[
|
||||
row.original.object as keyof typeof RequestTypeLabels
|
||||
]
|
||||
}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "input",
|
||||
header: "Message",
|
||||
size: 350,
|
||||
cell: ({ row }) => <LogMessageCell log={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "model",
|
||||
header: "Model",
|
||||
size: 190,
|
||||
cell: ({ row }) => {
|
||||
const provider = row.original.provider as ProviderName | undefined;
|
||||
const model = row.original.model;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{provider ? (
|
||||
<RenderProviderIcon
|
||||
provider={provider as ProviderIconType}
|
||||
size="xs"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col leading-tight">
|
||||
<span className="truncate font-mono text-[12px]">
|
||||
{model || "N/A"}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-[10.5px]">
|
||||
{provider ? getProviderLabel(provider) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "latency",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="logs-latency-sort-btn"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Latency
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const latency = row.original.latency;
|
||||
if (latency === undefined || latency === null) {
|
||||
return <div className="pl-4 font-mono text-xs">N/A</div>;
|
||||
}
|
||||
const tone =
|
||||
latency >= 5000
|
||||
? "bg-red-500"
|
||||
: latency >= 2000
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
const pct = Math.min(100, (latency / 5000) * 100);
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<span className="font-mono text-[12px] tabular-nums">
|
||||
{formatLatency(latency)}
|
||||
</span>
|
||||
<div className="relative h-1.5 w-[56px] overflow-hidden rounded-sm bg-zinc-200 dark:bg-zinc-700">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 rounded-sm opacity-85",
|
||||
tone,
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "tokens",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="logs-tokens-sort-btn"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Tokens
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
size: 190,
|
||||
cell: ({ row }) => {
|
||||
const tokenUsage = row.original.token_usage;
|
||||
if (!tokenUsage) {
|
||||
return <div className="pl-4 font-mono text-xs">N/A</div>;
|
||||
}
|
||||
const prompt = tokenUsage.prompt_tokens ?? 0;
|
||||
const completion = tokenUsage.completion_tokens ?? 0;
|
||||
const total = tokenUsage.total_tokens ?? 0;
|
||||
const hasSplit =
|
||||
tokenUsage.completion_tokens != null &&
|
||||
tokenUsage.prompt_tokens != null;
|
||||
const splitBase = prompt + completion || 1;
|
||||
const inPct = (prompt / splitBase) * 100;
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5 pl-4 leading-tight">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[12px] tabular-nums">
|
||||
{formatTokens(total)}
|
||||
</span>
|
||||
{hasSplit && (
|
||||
<div className="flex h-1.5 w-[64px] overflow-hidden rounded-sm">
|
||||
<div className="bg-blue-400" style={{ width: `${inPct}%` }} />
|
||||
<div className="flex-1 bg-violet-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasSplit && (
|
||||
<div className="text-muted-foreground font-mono text-[10.5px] tabular-nums">
|
||||
<span className="text-blue-500">{formatTokens(prompt)}</span>
|
||||
<span> / </span>
|
||||
<span className="text-violet-500">
|
||||
{formatTokens(completion)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "cost",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="logs-cost-sort-btn"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Cost
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.cost == null) {
|
||||
return <div className="pl-4 font-mono text-[12px]">N/A</div>;
|
||||
}
|
||||
return (
|
||||
<div className="pl-4 font-mono text-sm tabular-nums">
|
||||
{formatCost(row.original.cost)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const metadataColumns: ColumnDef<LogEntry>[] = metadataKeys.map((key) => ({
|
||||
id: `metadata_${key}`,
|
||||
header: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
size: 126,
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.metadata?.[key];
|
||||
return (
|
||||
<div className="max-w-[150px] truncate font-mono text-xs">
|
||||
{value ?? "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const actionsColumn: ColumnDef<LogEntry> = {
|
||||
id: "actions",
|
||||
size: 72,
|
||||
cell: ({ row }) => {
|
||||
const log = row.original;
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
data-testid="log-delete-btn"
|
||||
aria-label="Delete log"
|
||||
className="text-secondary-foreground/30 hover:bg-destructive/10 hover:text-destructive border-destructive/10"
|
||||
onClick={() => onDelete(log)}
|
||||
disabled={!hasDeleteAccess}
|
||||
>
|
||||
<Trash2 strokeWidth={1.5} />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [...baseColumns, ...metadataColumns, actionsColumn];
|
||||
};
|
||||
323
ui/app/workspace/logs/views/emptyState.tsx
Normal file
323
ui/app/workspace/logs/views/emptyState.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
|
||||
import { getExampleBaseUrl } from "@/lib/utils/port";
|
||||
import { AlertTriangle, Copy } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Provider = "openai" | "anthropic" | "genai" | "litellm" | "langchain";
|
||||
type Language = "python" | "typescript";
|
||||
|
||||
type Examples = {
|
||||
curl: string;
|
||||
sdk: {
|
||||
[P in Provider]: {
|
||||
[L in Language]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Common editor options to reduce duplication
|
||||
const EditorOptions = {
|
||||
scrollBeyondLastLine: false,
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: "off",
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
glyphMargin: false,
|
||||
} as const;
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language: string;
|
||||
onLanguageChange?: (language: string) => void;
|
||||
showLanguageSelect?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
function CodeBlock({ code, language, onLanguageChange, showLanguageSelect = false, readonly = true }: CodeBlockProps) {
|
||||
const { copy: copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||
{showLanguageSelect && onLanguageChange && (
|
||||
<Select value={language} onValueChange={onLanguageChange}>
|
||||
<SelectTrigger className="h-8 w-fit text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem className="text-xs" value="python">
|
||||
Python
|
||||
</SelectItem>
|
||||
<SelectItem className="text-xs" value="typescript">
|
||||
TypeScript
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(code)}>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CodeEditor className="w-full" code={code} lang={language} readonly={readonly} height={300} fontSize={14} options={EditorOptions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function EmptyState({ error }: EmptyStateProps) {
|
||||
const [language, setLanguage] = useState<Language>("python");
|
||||
|
||||
// Generate examples dynamically using the port utility
|
||||
const examples: Examples = useMemo(() => {
|
||||
const baseUrl = getExampleBaseUrl();
|
||||
|
||||
return {
|
||||
curl: `curl -X POST ${baseUrl}/v1/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello!"}
|
||||
]
|
||||
}'`,
|
||||
sdk: {
|
||||
openai: {
|
||||
python: `import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
base_url="${baseUrl}/openai",
|
||||
api_key="dummy-api-key" # Handled by Bifrost
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini", # or "provider/model" for other providers (anthropic/claude-3-sonnet)
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)`,
|
||||
typescript: `import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL: "${baseUrl}/openai",
|
||||
apiKey: "dummy-api-key", // Handled by Bifrost
|
||||
});
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini", // or "provider/model" for other providers (anthropic/claude-3-sonnet)
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
});`,
|
||||
},
|
||||
anthropic: {
|
||||
python: `import anthropic
|
||||
|
||||
client = anthropic.Anthropic(
|
||||
base_url="${baseUrl}/anthropic",
|
||||
api_key="dummy-api-key" # Handled by Bifrost
|
||||
)
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-3-sonnet-20240229", # or "provider/model" for other providers (openai/gpt-4o-mini)
|
||||
max_tokens=1000,
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)`,
|
||||
typescript: `import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
const anthropic = new Anthropic({
|
||||
baseURL: "${baseUrl}/anthropic",
|
||||
apiKey: "dummy-api-key", // Handled by Bifrost
|
||||
});
|
||||
|
||||
const response = await anthropic.messages.create({
|
||||
model: "claude-3-sonnet-20240229", // or "provider/model" for other providers (openai/gpt-4o-mini)
|
||||
max_tokens: 1000,
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
});`,
|
||||
},
|
||||
genai: {
|
||||
python: `from google import genai
|
||||
from google.genai.types import HttpOptions
|
||||
|
||||
client = genai.Client(
|
||||
api_key="dummy-api-key", # Handled by Bifrost
|
||||
http_options=HttpOptions(base_url="${baseUrl}/genai")
|
||||
)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-pro", # or "provider/model" for other providers (openai/gpt-4o-mini)
|
||||
contents="Hello!"
|
||||
)`,
|
||||
typescript: `import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
|
||||
const genAI = new GoogleGenerativeAI("dummy-api-key", { // Handled by Bifrost
|
||||
baseUrl: "${baseUrl}/genai",
|
||||
});
|
||||
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" }); // or "provider/model" for other providers (openai/gpt-4o-mini)
|
||||
const response = await model.generateContent("Hello!");`,
|
||||
},
|
||||
litellm: {
|
||||
python: `import litellm
|
||||
|
||||
litellm.api_base = "${baseUrl}/litellm"
|
||||
|
||||
response = litellm.completion(
|
||||
model="openai/gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)`,
|
||||
typescript: `import { completion } from "litellm";
|
||||
|
||||
const response = await completion({
|
||||
model: "openai/gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
api_base: "${baseUrl}/litellm",
|
||||
});`,
|
||||
},
|
||||
langchain: {
|
||||
python: `from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
|
||||
# Initialize ChatOpenAI with Bifrost
|
||||
llm = ChatOpenAI(
|
||||
model="gpt-4o-mini",
|
||||
api_key="dummy-api-key", # Handled by Bifrost
|
||||
base_url="${baseUrl}/langchain",
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
# Simple message
|
||||
messages = [HumanMessage(content="Hello from LangChain!")]
|
||||
response = llm.invoke(messages)
|
||||
|
||||
# Chain with prompt template
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
("system", "You are a helpful assistant."),
|
||||
("human", "{input}")
|
||||
])
|
||||
|
||||
chain = prompt | llm | StrOutputParser()
|
||||
result = chain.invoke({"input": "What is LangChain?"})`,
|
||||
typescript: `import { ChatOpenAI } from "@langchain/openai";
|
||||
import { HumanMessage } from "@langchain/core/messages";
|
||||
import { ChatPromptTemplate } from "@langchain/core/prompts";
|
||||
import { StringOutputParser } from "@langchain/core/output_parsers";
|
||||
|
||||
// Initialize ChatOpenAI with Bifrost
|
||||
const llm = new ChatOpenAI({
|
||||
model: "gpt-4o-mini",
|
||||
openAIApiKey: "dummy-api-key", // Handled by Bifrost
|
||||
clientOptions: {
|
||||
baseURL: "${baseUrl}/langchain",
|
||||
},
|
||||
maxTokens: 100,
|
||||
});
|
||||
|
||||
// Simple message
|
||||
const messages = [new HumanMessage("Hello from LangChain!")];
|
||||
const response = await llm.invoke(messages);
|
||||
|
||||
// Chain with prompt template
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
["system", "You are a helpful assistant."],
|
||||
["human", "{input}"],
|
||||
]);
|
||||
|
||||
const chain = prompt.pipe(llm).pipe(new StringOutputParser());
|
||||
const result = await chain.invoke({ input: "What is LangChain?" });`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isUnexpectedError = error && error.includes("An unexpected error occurred");
|
||||
|
||||
return (
|
||||
<div className="dark:bg-card flex w-full flex-col items-center justify-center space-y-8 bg-white">
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{isUnexpectedError ? "Looks like you haven't configured the log store in your config file." : error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="w-full space-y-6 p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Integrate under 60 seconds</h3>
|
||||
<p className="text-muted-foreground text-sm">Send your first request to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="curl" className="w-full rounded-lg border">
|
||||
<TabsList className="grid h-10 w-full grid-cols-6 rounded-t-lg rounded-b-none">
|
||||
<TabsTrigger value="curl">cURL</TabsTrigger>
|
||||
<TabsTrigger value="openai">OpenAI SDK</TabsTrigger>
|
||||
<TabsTrigger value="anthropic">Anthropic SDK</TabsTrigger>
|
||||
<TabsTrigger value="genai">Google GenAI SDK</TabsTrigger>
|
||||
<TabsTrigger value="litellm">LiteLLM SDK</TabsTrigger>
|
||||
<TabsTrigger value="langchain">LangChain SDK</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="curl" className="px-4">
|
||||
<CodeBlock code={examples.curl} language="bash" readonly={false} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="openai" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.openai[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="anthropic" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.anthropic[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="genai" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.genai[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="litellm" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.litellm[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="langchain" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.langchain[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
ui/app/workspace/logs/views/imageView.tsx
Normal file
177
ui/app/workspace/logs/views/imageView.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { BifrostImageGenerationOutput, ImageEditInput, ImageVariationInput } from "@/lib/types/logs";
|
||||
import { Image, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ImageMessage } from "@/components/chat/ImageMessage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RequestTypeLabels } from "@/lib/constants/logs";
|
||||
|
||||
interface ImageGenerationInput {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface ImageViewProps {
|
||||
imageInput?: ImageGenerationInput;
|
||||
imageEditInput?: ImageEditInput;
|
||||
imageVariationInput?: ImageVariationInput;
|
||||
imageOutput?: BifrostImageGenerationOutput;
|
||||
requestType?: string;
|
||||
}
|
||||
|
||||
// Detect MIME type from base64 magic bytes and return a data URL
|
||||
function getImageSrc(b64: string): string {
|
||||
if (b64.startsWith("/9j/")) return `data:image/jpeg;base64,${b64}`;
|
||||
if (b64.startsWith("iVBOR")) return `data:image/png;base64,${b64}`;
|
||||
if (b64.startsWith("UklGR")) return `data:image/webp;base64,${b64}`;
|
||||
if (b64.startsWith("R0lGO")) return `data:image/gif;base64,${b64}`;
|
||||
return `data:image/png;base64,${b64}`;
|
||||
}
|
||||
|
||||
// Helper function to get method type label from request type
|
||||
function getMethodTypeLabel(requestType?: string): string {
|
||||
if (!requestType) return "Image Generation";
|
||||
|
||||
const normalizedType = requestType.toLowerCase();
|
||||
if (normalizedType.includes("image_edit")) {
|
||||
return RequestTypeLabels[normalizedType as keyof typeof RequestTypeLabels] || "Image Edit";
|
||||
}
|
||||
if (normalizedType.includes("image_variation")) {
|
||||
return RequestTypeLabels[normalizedType as keyof typeof RequestTypeLabels] || "Image Variation";
|
||||
}
|
||||
return RequestTypeLabels[normalizedType as keyof typeof RequestTypeLabels] || "Image Generation";
|
||||
}
|
||||
|
||||
export default function ImageView({ imageInput, imageEditInput, imageVariationInput, imageOutput, requestType }: ImageViewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
// Get all valid images
|
||||
const images = imageOutput?.data?.filter((img) => img.url || img.b64_json) ?? [];
|
||||
const totalImages = images.length;
|
||||
const currentImage = images[currentIndex] ?? null;
|
||||
|
||||
// Get method type label
|
||||
const methodTypeLabel = getMethodTypeLabel(requestType);
|
||||
|
||||
// Clamp currentIndex when images array changes to ensure it's always valid
|
||||
useEffect(() => {
|
||||
if (totalImages === 0) {
|
||||
setCurrentIndex(0);
|
||||
} else {
|
||||
setCurrentIndex((prev) => Math.min(prev, totalImages - 1));
|
||||
}
|
||||
}, [totalImages]);
|
||||
|
||||
// Looping navigation
|
||||
const goToPrevious = () => setCurrentIndex((prev) => (prev === 0 ? totalImages - 1 : prev - 1));
|
||||
const goToNext = () => setCurrentIndex((prev) => (prev === totalImages - 1 ? 0 : prev + 1));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Image Input */}
|
||||
{imageInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Image className="h-4 w-4" />
|
||||
{methodTypeLabel} Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">PROMPT</div>
|
||||
<div className="font-mono text-xs">{imageInput.prompt}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Edit Input */}
|
||||
{imageEditInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Image className="h-4 w-4" />
|
||||
{methodTypeLabel} Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
{imageEditInput.images && imageEditInput.images.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">INPUT IMAGES</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{imageEditInput.images.map((img, i) =>
|
||||
img.image ? (
|
||||
<img
|
||||
key={i}
|
||||
src={getImageSrc(img.image)}
|
||||
alt={`Input image ${i + 1}`}
|
||||
className="max-h-48 max-w-48 rounded border object-contain"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">PROMPT</div>
|
||||
<div className="font-mono text-xs">{imageEditInput.prompt}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Variation Input */}
|
||||
{imageVariationInput && imageVariationInput.image?.image && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Image className="h-4 w-4" />
|
||||
{methodTypeLabel} Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">INPUT IMAGE</div>
|
||||
<img
|
||||
src={getImageSrc(imageVariationInput.image.image)}
|
||||
alt="Input image"
|
||||
className="max-h-48 max-w-48 rounded border object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Output */}
|
||||
{currentImage && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Image className="h-4 w-4" />
|
||||
{methodTypeLabel} Output
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
{currentImage && (
|
||||
<>
|
||||
{currentImage.revised_prompt && (
|
||||
<div className="mb-4">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">REVISED PROMPT</div>
|
||||
<div className="font-mono text-xs">{currentImage.revised_prompt}</div>
|
||||
</div>
|
||||
)}
|
||||
<ImageMessage
|
||||
image={{
|
||||
...currentImage,
|
||||
output_format: imageOutput?.output_format,
|
||||
}}
|
||||
/>
|
||||
|
||||
{totalImages > 1 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-4">
|
||||
<Button variant="outline" size="sm" onClick={goToPrevious} aria-label="Previous image" title="Previous image">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{currentIndex + 1} / {totalImages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={goToNext} aria-label="Next image" title="Next image">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
ui/app/workspace/logs/views/logChatMessageView.tsx
Normal file
250
ui/app/workspace/logs/views/logChatMessageView.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { ChatMessage, ContentBlock } from "@/lib/types/logs";
|
||||
import { cleanJson, isJson } from "@/lib/utils/validation";
|
||||
import AudioPlayer from "./audioPlayer";
|
||||
import CollapsibleBox from "./collapsibleBox";
|
||||
|
||||
interface LogChatMessageViewProps {
|
||||
message: ChatMessage;
|
||||
audioFormat?: string; // Optional audio format from request params
|
||||
}
|
||||
|
||||
function ContentBlockView({ block }: { block: ContentBlock; index: number }) {
|
||||
const blockType = block.type.replaceAll("_", " ");
|
||||
|
||||
// Handle text content
|
||||
if (block.text) {
|
||||
if (isJson(block.text)) {
|
||||
const jsonContent = JSON.stringify(cleanJson(block.text), null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockType} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={200}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CollapsibleBox title={blockType} onCopy={() => block.text || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{block.text}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle image content
|
||||
if (block.image_url) {
|
||||
const jsonContent = JSON.stringify(block.image_url, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockType} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle audio content
|
||||
if (block.input_audio) {
|
||||
const jsonContent = JSON.stringify(block.input_audio, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockType} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LogChatMessageView({ message, audioFormat }: LogChatMessageViewProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{/* Role header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium capitalize">{message.role}</span>
|
||||
{message.tool_call_id && <span className="text-muted-foreground text-xs">Tool Call ID: {message.tool_call_id}</span>}
|
||||
</div>
|
||||
|
||||
{/* Handle reasoning content */}
|
||||
{message.reasoning && (
|
||||
<>
|
||||
{isJson(message.reasoning) ? (
|
||||
<CollapsibleBox title="Reasoning" onCopy={() => JSON.stringify(cleanJson(message.reasoning), null, 2)} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={200}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.reasoning), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
) : (
|
||||
<CollapsibleBox title="Reasoning" onCopy={() => message.reasoning || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar text-muted-foreground max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap italic">
|
||||
{message.reasoning}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle refusal content */}
|
||||
{message.refusal && (
|
||||
<>
|
||||
{isJson(message.refusal) ? (
|
||||
<CollapsibleBox title="Refusal" onCopy={() => JSON.stringify(cleanJson(message.refusal), null, 2)} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.refusal), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
) : (
|
||||
<CollapsibleBox title="Refusal" onCopy={() => message.refusal || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap text-red-800">
|
||||
{message.refusal}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle content */}
|
||||
{message.content && (
|
||||
<>
|
||||
{typeof message.content === "string" ? (
|
||||
<>
|
||||
{isJson(message.content) ? (
|
||||
<CollapsibleBox
|
||||
title="Content"
|
||||
onCopy={() => JSON.stringify(cleanJson(message.content as string), null, 2)}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={250}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.content), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
) : (
|
||||
<CollapsibleBox title="Content" onCopy={() => (message.content as string) || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
Array.isArray(message.content) &&
|
||||
message.content.map((block, blockIndex) => <ContentBlockView key={blockIndex} block={block} index={blockIndex} />)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle tool calls */}
|
||||
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||
<>
|
||||
{message.tool_calls.map((toolCall, index) => {
|
||||
const jsonContent = JSON.stringify(toolCall, null, 2);
|
||||
return (
|
||||
<CollapsibleBox key={index} title={`Tool Call: ${toolCall.function?.name || `#${index + 1}`}`} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle annotations */}
|
||||
{message.annotations && message.annotations.length > 0 && (
|
||||
<CollapsibleBox title="Annotations" onCopy={() => JSON.stringify(message.annotations, null, 2)} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(message.annotations, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
|
||||
{/* Handle audio output */}
|
||||
{message.audio && (
|
||||
<CollapsibleBox title="Audio Output" collapsedHeight={150}>
|
||||
<div className="space-y-4 px-6 py-4">
|
||||
{message.audio.transcript && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">Transcript:</div>
|
||||
<div className="font-mono text-xs break-words whitespace-pre-wrap">{message.audio.transcript}</div>
|
||||
</div>
|
||||
)}
|
||||
{message.audio.data && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">Audio:</div>
|
||||
<AudioPlayer src={message.audio.data} format={audioFormat} />
|
||||
</div>
|
||||
)}
|
||||
{message.audio.id && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
ID: {message.audio.id} | Expires:{" "}
|
||||
{message.audio.expires_at && Number.isFinite(message.audio.expires_at)
|
||||
? new Date(message.audio.expires_at * 1000).toLocaleString()
|
||||
: "N/A"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
ui/app/workspace/logs/views/logEntryDetailsView.tsx
Normal file
49
ui/app/workspace/logs/views/logEntryDetailsView.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
isBeta?: boolean;
|
||||
valueClassName?: string;
|
||||
label: string;
|
||||
value: React.ReactNode | null;
|
||||
hideExpandable?: boolean;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
export default function LogEntryDetailsView(props: Props) {
|
||||
if (props.value === null) {
|
||||
return null;
|
||||
}
|
||||
const orientation = props.orientation || "vertical";
|
||||
return (
|
||||
<div
|
||||
className={cn("items-top flex flex-col gap-2", {
|
||||
[`${props.className}`]: props.className !== undefined,
|
||||
"items-start": props.align === "left" || props.align === undefined,
|
||||
"items-end": props.align === "right",
|
||||
})}
|
||||
>
|
||||
<div className={props.containerClassName}>
|
||||
{props.label !== "" && (
|
||||
<div className="text-muted-foreground flex shrink-0 flex-row items-center gap-2 pb-2 text-xs font-medium">
|
||||
{props.label.toUpperCase().replace(/_/g, " ")}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("text-md flex text-xs font-medium overflow-ellipsis transition-transform delay-75", {
|
||||
"w-full flex-col items-center gap-2": orientation === "horizontal",
|
||||
"flex-row items-start gap-2": orientation === "vertical",
|
||||
[`${props.valueClassName}`]: props.valueClassName !== undefined,
|
||||
"text-end": props.align === "right",
|
||||
})}
|
||||
>
|
||||
<div className="text-bifrost-gray-300 flex-1 text-sm break-all">
|
||||
{typeof props.value === "boolean" ? String(props.value) : props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
451
ui/app/workspace/logs/views/logResponsesMessageView.tsx
Normal file
451
ui/app/workspace/logs/views/logResponsesMessageView.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { ResponsesMessage, ResponsesMessageContentBlock } from "@/lib/types/logs";
|
||||
import { cleanJson, isJson } from "@/lib/utils/validation";
|
||||
import CollapsibleBox from "./collapsibleBox";
|
||||
|
||||
interface LogResponsesMessageViewProps {
|
||||
messages: ResponsesMessage[];
|
||||
}
|
||||
|
||||
function ContentBlockView({ block }: { block: ResponsesMessageContentBlock; index: number }) {
|
||||
const getBlockTitle = (type: string) => {
|
||||
switch (type) {
|
||||
case "input_text":
|
||||
return "Input Text";
|
||||
case "input_image":
|
||||
return "Input Image";
|
||||
case "input_file":
|
||||
return "Input File";
|
||||
case "input_audio":
|
||||
return "Input Audio";
|
||||
case "output_text":
|
||||
return "Output Text";
|
||||
case "reasoning_text":
|
||||
return "Reasoning Text";
|
||||
case "refusal":
|
||||
return "Refusal";
|
||||
default:
|
||||
return type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
};
|
||||
|
||||
const blockTitle = getBlockTitle(block.type);
|
||||
|
||||
// Handle text content
|
||||
if (block.text) {
|
||||
if (isJson(block.text)) {
|
||||
const jsonContent = JSON.stringify(cleanJson(block.text), null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={200}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => block.text || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs whitespace-pre-wrap">{block.text}</div>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle image content
|
||||
if (block.image_url) {
|
||||
const jsonContent = JSON.stringify(
|
||||
{
|
||||
image_url: block.image_url,
|
||||
...(block.detail && { detail: block.detail }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle file content
|
||||
if (block.file_id || block.file_data || block.file_url) {
|
||||
const jsonContent = JSON.stringify(
|
||||
{
|
||||
...(block.filename && { filename: block.filename }),
|
||||
...(block.file_id && { file_id: block.file_id }),
|
||||
...(block.file_url && { file_url: block.file_url }),
|
||||
...(block.file_data && { file_data: "[Base64 encoded data]" }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle audio content
|
||||
if (block.input_audio) {
|
||||
const jsonContent = JSON.stringify(block.input_audio, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle refusal content
|
||||
if (block.refusal) {
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => block.refusal || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs text-red-800">{block.refusal}</div>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle annotations
|
||||
if (block.annotations && block.annotations.length > 0) {
|
||||
const jsonContent = JSON.stringify(block.annotations, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title="Annotations" onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle log probabilities
|
||||
if (block.logprobs && block.logprobs.length > 0) {
|
||||
const jsonContent = JSON.stringify(block.logprobs, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title="Log Probabilities" onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MessageView({ message, index }: { message: ResponsesMessage; index: number }) {
|
||||
const getMessageTitle = () => {
|
||||
if (message.type) {
|
||||
switch (message.type) {
|
||||
case "reasoning":
|
||||
return "Reasoning";
|
||||
case "message":
|
||||
return message.role ? `${message.role.charAt(0).toUpperCase() + message.role.slice(1)} Message` : "Message";
|
||||
case "function_call":
|
||||
return `Function Call: ${message.name || "Unknown"}`;
|
||||
case "function_call_output":
|
||||
return `Function Call Output${message.call_id ? `: ${message.call_id}` : ""}`;
|
||||
case "file_search_call":
|
||||
return "File Search";
|
||||
case "web_search_call":
|
||||
return "Web Search";
|
||||
case "computer_call":
|
||||
return "Computer Action";
|
||||
case "computer_call_output":
|
||||
return "Computer Action Output";
|
||||
case "code_interpreter_call":
|
||||
return "Code Interpreter";
|
||||
case "mcp_call":
|
||||
return "MCP Tool Call";
|
||||
case "custom_tool_call":
|
||||
return "Custom Tool Call";
|
||||
case "custom_tool_call_output":
|
||||
return "Custom Tool Output";
|
||||
case "image_generation_call":
|
||||
return "Image Generation";
|
||||
case "refusal":
|
||||
return "Refusal";
|
||||
default:
|
||||
return message.type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
}
|
||||
return message.role ? `${message.role.charAt(0).toUpperCase() + message.role.slice(1)}` : "Message";
|
||||
};
|
||||
|
||||
if (message.type == "reasoning" && (!message.summary || message.summary.length === 0) && !message.encrypted_content && !message.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageTitle = getMessageTitle();
|
||||
|
||||
return (
|
||||
<div key={`message-${index}`} className="flex w-full flex-col gap-2">
|
||||
{/* Message title header */}
|
||||
<div className="text-sm font-medium">{messageTitle}</div>
|
||||
|
||||
{/* Handle reasoning content */}
|
||||
{message.type === "reasoning" && message.summary && message.summary.length > 0 && (
|
||||
<>
|
||||
{message.summary.every((item) => item.type === "summary_text") ? (
|
||||
// Display as readable text when all items are summary_text
|
||||
message.summary.map((reasoningContent, idx) => (
|
||||
<CollapsibleBox key={idx} title={`Summary #${idx + 1}`} onCopy={() => reasoningContent.text || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{reasoningContent.text}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
))
|
||||
) : (
|
||||
// Fallback to JSON display for mixed or non-text types
|
||||
<CollapsibleBox title="Summary" onCopy={() => JSON.stringify(message.summary, null, 2)} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={300}
|
||||
wrap={true}
|
||||
code={JSON.stringify(message.summary, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle encrypted reasoning content */}
|
||||
{message.type === "reasoning" && message.encrypted_content && (
|
||||
<CollapsibleBox title="Encrypted Reasoning Content" onCopy={() => message.encrypted_content || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{message.encrypted_content}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
|
||||
{/* Handle regular content */}
|
||||
{message.content && (
|
||||
<>
|
||||
{typeof message.content === "string" ? (
|
||||
<>
|
||||
{isJson(message.content) ? (
|
||||
<CollapsibleBox
|
||||
title="Content"
|
||||
onCopy={() => JSON.stringify(cleanJson(message.content as string), null, 2)}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={250}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.content), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
) : (
|
||||
<CollapsibleBox title="Content" onCopy={() => (message.content as string) || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
Array.isArray(message.content) &&
|
||||
message.content.map((block, blockIndex) => <ContentBlockView key={blockIndex} block={block} index={blockIndex} />)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle tool call specific fields */}
|
||||
{(message.call_id || message.name || message.arguments) && (
|
||||
<CollapsibleBox
|
||||
title="Tool Details"
|
||||
onCopy={() =>
|
||||
JSON.stringify(
|
||||
{
|
||||
...(message.call_id && { call_id: message.call_id }),
|
||||
...(message.name && { name: message.name }),
|
||||
...(message.arguments && { arguments: isJson(message.arguments) ? cleanJson(message.arguments) : message.arguments }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(
|
||||
{
|
||||
...(message.call_id && { call_id: message.call_id }),
|
||||
...(message.name && { name: message.name }),
|
||||
...(message.arguments && { arguments: isJson(message.arguments) ? cleanJson(message.arguments) : message.arguments }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
|
||||
{/* Handle function call output */}
|
||||
{message.output !== undefined && (
|
||||
<CollapsibleBox
|
||||
title="Output"
|
||||
onCopy={() => (typeof message.output === "string" ? message.output : JSON.stringify(message.output, null, 2))}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
{typeof message.output === "string" ? (
|
||||
isJson(message.output) ? (
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.output), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
) : (
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{message.output}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(message.output, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
|
||||
{/* Handle additional tool-specific fields */}
|
||||
{Object.keys(message).some(
|
||||
(key) => !["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(key),
|
||||
) && (
|
||||
<CollapsibleBox
|
||||
title="Additional Fields"
|
||||
onCopy={() =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(message).filter(
|
||||
([key]) =>
|
||||
!["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(
|
||||
key,
|
||||
),
|
||||
),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(message).filter(
|
||||
([key]) =>
|
||||
!["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(
|
||||
key,
|
||||
),
|
||||
),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogResponsesMessageView({ messages }: LogResponsesMessageViewProps) {
|
||||
if (!messages || messages.length === 0) {
|
||||
return (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="text-muted-foreground px-6 py-4 text-center text-sm">No responses messages available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<MessageView key={index} message={message} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
ui/app/workspace/logs/views/logsHeaderView.tsx
Normal file
191
ui/app/workspace/logs/views/logsHeaderView.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { ColumnConfigDropdown, type ColumnConfigEntry } from "@/components/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Command, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { getErrorMessage, useRecalculateLogCostsMutation } from "@/lib/store";
|
||||
import type { LogFilters as LogFiltersType } from "@/lib/types/logs";
|
||||
import { getRangeForPeriod, TIME_PERIODS } from "@/lib/utils/timeRange";
|
||||
import { Calculator, MoreVertical, Radio, RefreshCw, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LogsHeaderViewProps {
|
||||
filters: LogFiltersType;
|
||||
onFiltersChange: (filters: LogFiltersType) => void;
|
||||
fetchLogs: () => Promise<void>;
|
||||
fetchStats: () => Promise<void>;
|
||||
fetchHistogram: () => Promise<void>;
|
||||
loading?: boolean;
|
||||
polling: boolean;
|
||||
onPollToggle: (enabled: boolean) => void;
|
||||
period: string;
|
||||
onPeriodChange: (period: string, from: Date, to: Date) => void;
|
||||
/** Column config for the ColumnConfigDropdown */
|
||||
columnEntries: ColumnConfigEntry[];
|
||||
columnLabels: Record<string, string>;
|
||||
onToggleColumnVisibility: (id: string) => void;
|
||||
onResetColumns: () => void;
|
||||
}
|
||||
|
||||
export function LogsHeaderView({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
fetchLogs,
|
||||
fetchStats,
|
||||
fetchHistogram,
|
||||
loading = false,
|
||||
polling,
|
||||
onPollToggle,
|
||||
period,
|
||||
onPeriodChange,
|
||||
columnEntries,
|
||||
columnLabels,
|
||||
onToggleColumnVisibility,
|
||||
onResetColumns,
|
||||
}: LogsHeaderViewProps) {
|
||||
const [openMoreActionsPopover, setOpenMoreActionsPopover] = useState(false);
|
||||
const [localSearch, setLocalSearch] = useState(filters.content_search || "");
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const filtersRef = useRef<LogFiltersType>(filters);
|
||||
const [recalculateCosts] = useRecalculateLogCostsMutation();
|
||||
|
||||
const [startTime, setStartTime] = useState<Date | undefined>(filters.start_time ? new Date(filters.start_time) : undefined);
|
||||
const [endTime, setEndTime] = useState<Date | undefined>(filters.end_time ? new Date(filters.end_time) : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setStartTime(filters.start_time ? new Date(filters.start_time) : undefined);
|
||||
setEndTime(filters.end_time ? new Date(filters.end_time) : undefined);
|
||||
}, [filters.start_time, filters.end_time]);
|
||||
|
||||
useEffect(() => {
|
||||
filtersRef.current = filters;
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSearch(filters.content_search || "");
|
||||
}, [filters.content_search]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRecalculateCosts = useCallback(async () => {
|
||||
try {
|
||||
const response = await recalculateCosts({ filters }).unwrap();
|
||||
await fetchLogs();
|
||||
await fetchStats();
|
||||
setOpenMoreActionsPopover(false);
|
||||
toast.success(`Recalculated costs for ${response.updated} logs`, {
|
||||
description: `${response.updated} logs updated, ${response.skipped} logs skipped, ${response.remaining} logs remaining`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err));
|
||||
}
|
||||
}, [filters, recalculateCosts, fetchLogs, fetchStats]);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setLocalSearch(value);
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
onFiltersChange({ ...filtersRef.current, content_search: value });
|
||||
}, 500);
|
||||
},
|
||||
[onFiltersChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex grow items-center justify-between space-x-2">
|
||||
<Button
|
||||
data-testid="logs-refresh-btn"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7.5 disabled:opacity-100"
|
||||
onClick={() => {
|
||||
fetchLogs();
|
||||
fetchStats();
|
||||
fetchHistogram();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="logs-live-btn"
|
||||
variant={polling ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7.5"
|
||||
onClick={() => onPollToggle(!polling)}
|
||||
>
|
||||
{polling ? <Radio className="h-4 w-4 animate-pulse" /> : <Radio className="h-4 w-4" />}
|
||||
Live
|
||||
</Button>
|
||||
<div className="border-input flex h-7.5 flex-1 items-center gap-2 rounded-sm border">
|
||||
<Search className="mr-0.5 ml-2 size-4" />
|
||||
<Input
|
||||
type="text"
|
||||
className="!h-7 rounded-tl-none rounded-tr-sm rounded-br-sm rounded-bl-none border-none bg-slate-50 shadow-none outline-none focus-visible:ring-0"
|
||||
placeholder="Search logs"
|
||||
value={localSearch}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DateTimePickerWithRange
|
||||
triggerTestId="filter-date-range"
|
||||
dateTime={{ from: startTime, to: endTime }}
|
||||
predefinedPeriod={period || undefined}
|
||||
onDateTimeUpdate={(p) => {
|
||||
setStartTime(p.from);
|
||||
setEndTime(p.to);
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
start_time: p.from?.toISOString(),
|
||||
end_time: p.to?.toISOString(),
|
||||
});
|
||||
}}
|
||||
preDefinedPeriods={TIME_PERIODS}
|
||||
onPredefinedPeriodChange={(periodValue) => {
|
||||
if (!periodValue) return;
|
||||
const { from, to } = getRangeForPeriod(periodValue);
|
||||
setStartTime(from);
|
||||
setEndTime(to);
|
||||
// Relative period: store it in URL and update timestamps via parent
|
||||
onPeriodChange(periodValue, from, to);
|
||||
}}
|
||||
/>
|
||||
<Popover open={openMoreActionsPopover} onOpenChange={setOpenMoreActionsPopover}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7.5 w-7.5">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="bg-accent w-[250px] p-2" align="end">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandItem className="hover:bg-accent/50 cursor-pointer" onSelect={handleRecalculateCosts}>
|
||||
<Calculator className="text-muted-foreground size-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">Recalculate costs</span>
|
||||
<span className="text-muted-foreground text-xs">For all logs that don't have a cost</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ColumnConfigDropdown
|
||||
entries={columnEntries}
|
||||
labels={columnLabels}
|
||||
onToggleVisibility={onToggleColumnVisibility}
|
||||
onReset={onResetColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
ui/app/workspace/logs/views/logsTable.tsx
Normal file
279
ui/app/workspace/logs/views/logsTable.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
buildPinStyle,
|
||||
type ColumnConfigEntry,
|
||||
DraggableColumnHeader,
|
||||
PIN_SHADOW_LEFT,
|
||||
PIN_SHADOW_RIGHT,
|
||||
useHeaderCellRefs,
|
||||
usePinOffsets,
|
||||
} from "@/components/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useTablePageSize } from "@/hooks/useTablePageSize";
|
||||
import type { LogEntry, Pagination } from "@/lib/types/logs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnOrderState, ColumnPinningState, VisibilityState } from "@tanstack/react-table";
|
||||
import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table";
|
||||
import { ChevronLeft, ChevronRight, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface DataTableProps {
|
||||
columns: ColumnDef<LogEntry>[];
|
||||
data: LogEntry[];
|
||||
totalItems: number;
|
||||
pagination: Pagination;
|
||||
onPaginationChange: (pagination: Pagination) => void;
|
||||
onRowClick?: (log: LogEntry, columnId: string) => void;
|
||||
polling: boolean;
|
||||
loading?: boolean;
|
||||
onRefresh: () => void;
|
||||
/** Column config — computed by the parent via useColumnConfig */
|
||||
columnEntries: ColumnConfigEntry[];
|
||||
columnOrder: ColumnOrderState;
|
||||
columnVisibility: VisibilityState;
|
||||
columnPinning: ColumnPinningState;
|
||||
onToggleColumnVisibility: (id: string) => void;
|
||||
onTogglePin: (id: string, side: "left" | "right") => void;
|
||||
onReorderColumns: (entries: ColumnConfigEntry[]) => void;
|
||||
}
|
||||
|
||||
export function LogsDataTable({
|
||||
columns,
|
||||
data,
|
||||
totalItems,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
onRowClick,
|
||||
polling,
|
||||
loading,
|
||||
onRefresh,
|
||||
columnEntries,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
columnPinning,
|
||||
onToggleColumnVisibility,
|
||||
onTogglePin,
|
||||
onReorderColumns,
|
||||
}: DataTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: pagination.sort_by, desc: pagination.order === "desc" }]);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const calculatedPageSize = useTablePageSize(tableContainerRef);
|
||||
|
||||
const fixedColumnIds = useMemo(() => new Set<string>([]), []);
|
||||
|
||||
// Measure actual header cell widths for pixel-perfect pin offsets
|
||||
const { headerCellRefs, setHeaderCellRef } = useHeaderCellRefs();
|
||||
const pinOffsets = usePinOffsets(headerCellRefs, columnPinning);
|
||||
|
||||
// Shadow on the edge of pinned groups
|
||||
const lastLeftPinId = columnPinning.left?.at(-1);
|
||||
const firstRightPinId = columnPinning.right?.at(0);
|
||||
|
||||
// Handle native drag-and-drop reorder
|
||||
const handleColumnDrop = useCallback(
|
||||
(draggedId: string, targetId: string) => {
|
||||
const newEntries = [...columnEntries];
|
||||
const draggedIdx = newEntries.findIndex((e) => e.id === draggedId);
|
||||
const targetIdx = newEntries.findIndex((e) => e.id === targetId);
|
||||
if (draggedIdx === -1 || targetIdx === -1) return;
|
||||
const [moved] = newEntries.splice(draggedIdx, 1);
|
||||
newEntries.splice(targetIdx, 0, moved);
|
||||
onReorderColumns(newEntries);
|
||||
},
|
||||
[columnEntries, onReorderColumns],
|
||||
);
|
||||
|
||||
// Refs to avoid stale closures in the page size effect
|
||||
const paginationRef = useRef(pagination);
|
||||
const onPaginationChangeRef = useRef(onPaginationChange);
|
||||
paginationRef.current = pagination;
|
||||
onPaginationChangeRef.current = onPaginationChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (calculatedPageSize && calculatedPageSize > paginationRef.current.limit) {
|
||||
onPaginationChangeRef.current({
|
||||
...paginationRef.current,
|
||||
limit: calculatedPageSize,
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
}, [calculatedPageSize]);
|
||||
|
||||
const handleSortingChange = (updaterOrValue: SortingState | ((old: SortingState) => SortingState)) => {
|
||||
const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
|
||||
setSorting(newSorting);
|
||||
if (newSorting.length > 0) {
|
||||
const { id, desc } = newSorting[0];
|
||||
onPaginationChange({
|
||||
...pagination,
|
||||
sort_by: id as "timestamp" | "latency" | "tokens" | "cost",
|
||||
order: desc ? "desc" : "asc",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
pageCount: Math.ceil(totalItems / pagination.limit),
|
||||
state: {
|
||||
sorting,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
columnPinning,
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
});
|
||||
|
||||
const hasItems = totalItems > 0;
|
||||
const currentPage = hasItems ? Math.floor(pagination.offset / pagination.limit) + 1 : 0;
|
||||
const totalPages = hasItems ? Math.ceil(totalItems / pagination.limit) : 0;
|
||||
const startItem = hasItems ? pagination.offset + 1 : 0;
|
||||
const endItem = hasItems ? Math.min(pagination.offset + pagination.limit, totalItems) : 0;
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
const newOffset = (page - 1) * pagination.limit;
|
||||
onPaginationChange({
|
||||
...pagination,
|
||||
offset: newOffset,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div ref={tableContainerRef} className="min-h-0 flex-1 overflow-hidden rounded-sm border">
|
||||
<Table containerClassName="h-full overflow-auto">
|
||||
<thead className={cn("[&_tr]:border-b px-2 sticky top-0 z-10 bg-[#f9f9f9] dark:bg-[#27272a]")}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className="hover:bg-muted/50 dark:hover:bg-muted/75 data-[state=selected]:bg-muted border-b transition-colors"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DraggableColumnHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
isConfigurable={!fixedColumnIds.has(header.column.id)}
|
||||
pinStyle={buildPinStyle(header.column, pinOffsets)}
|
||||
pinnedHeaderClassName="bg-[#f9f9f9] dark:bg-[#27272a]"
|
||||
className={cn(
|
||||
header.column.id === lastLeftPinId && PIN_SHADOW_LEFT,
|
||||
header.column.id === firstRightPinId && PIN_SHADOW_RIGHT,
|
||||
)}
|
||||
onHide={onToggleColumnVisibility}
|
||||
onPin={onTogglePin}
|
||||
onDrop={handleColumnDrop}
|
||||
cellRef={setHeaderCellRef(header.column.id)}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<TableBody>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="h-12 text-center">
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 text-sm">
|
||||
{polling ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Waiting for new logs...
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
data-testid="logs-table-refresh-btn"
|
||||
className="hover:text-foreground inline-flex items-center gap-1.5 transition-colors"
|
||||
variant={"ghost"}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="hover:bg-muted/50 group/table-row min-h-[40px] cursor-pointer">
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const pinned = cell.column.getIsPinned();
|
||||
const size = cell.column.getSize();
|
||||
return (
|
||||
<TableCell
|
||||
onClick={() => onRowClick?.(row.original, cell.column.id)}
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: size,
|
||||
minWidth: size,
|
||||
maxWidth: size,
|
||||
...buildPinStyle(cell.column, pinOffsets),
|
||||
}}
|
||||
className={cn(
|
||||
"py-1.5 align-middle",
|
||||
pinned && "bg-card",
|
||||
cell.column.id === lastLeftPinId && PIN_SHADOW_LEFT,
|
||||
cell.column.id === firstRightPinId && PIN_SHADOW_RIGHT,
|
||||
"group-hover/table-row:bg-[#f7f7f7] dark:group-hover/table-row:bg-[#232327]",
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results found. Try adjusting your filters and/or time range.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex shrink-0 items-center justify-between text-xs" data-testid="pagination">
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
{startItem.toLocaleString()}-{endItem.toLocaleString()} of {totalItems.toLocaleString()} entries
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
data-testid="prev-page"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Page</span>
|
||||
<span>{currentPage}</span>
|
||||
<span>of {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={totalPages === 0 || currentPage >= totalPages}
|
||||
data-testid="next-page"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
540
ui/app/workspace/logs/views/logsVolumeChart.tsx
Normal file
540
ui/app/workspace/logs/views/logsVolumeChart.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { HistogramBucket, LogsHistogramResponse } from "@/lib/types/logs";
|
||||
import { ChevronDown, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
Component,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
const requestFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
function formatRequest(requests: number): string {
|
||||
return requestFormatter.format(requests);
|
||||
}
|
||||
|
||||
// Empty chart placeholder when data fails to render
|
||||
function EmptyChart() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={[
|
||||
{ name: "", value: 0 },
|
||||
{ name: " ", value: 0 },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-zinc-200 dark:stroke-zinc-700"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 13, className: "fill-zinc-500", dy: 5 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 13, className: "fill-zinc-500" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={40}
|
||||
domain={[0, 1]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Error boundary to catch Recharts rendering errors
|
||||
class ChartErrorBoundary extends Component<
|
||||
{ children: ReactNode; resetKey?: string },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; resetKey?: string }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(_: Error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(
|
||||
props: { resetKey?: string },
|
||||
state: { hasError: boolean; prevResetKey?: string },
|
||||
) {
|
||||
// Reset error state when resetKey changes
|
||||
if (props.resetKey !== state.prevResetKey) {
|
||||
return { hasError: false, prevResetKey: props.resetKey };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
|
||||
console.warn("Chart rendering error:", error.message);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <EmptyChart />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface LogsVolumeChartProps {
|
||||
data: LogsHistogramResponse | null;
|
||||
loading?: boolean;
|
||||
onTimeRangeChange: (startTime: number, endTime: number) => void;
|
||||
onResetZoom?: () => void;
|
||||
isZoomed?: boolean;
|
||||
startTime: number; // Unix timestamp in seconds
|
||||
endTime: number; // Unix timestamp in seconds
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// Format timestamp based on bucket size
|
||||
function formatTimestamp(timestamp: string, bucketSizeSeconds: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
if (bucketSizeSeconds >= 86400) {
|
||||
// Daily buckets: "Jan 20"
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
} else if (bucketSizeSeconds >= 3600) {
|
||||
// Hourly buckets: "10:00"
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
} else {
|
||||
// Sub-hourly: "10:15"
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Format full timestamp for tooltip
|
||||
function formatFullTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
type LogVolumeDataPoint = HistogramBucket & {
|
||||
formattedTime: string;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload?: LogVolumeDataPoint }>;
|
||||
}
|
||||
|
||||
type ChartMouseEvent = { activeTooltipIndex?: number | string | null };
|
||||
|
||||
// Custom tooltip component
|
||||
function CustomTooltip({ active, payload }: CustomTooltipProps) {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const data = payload[0]?.payload;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-zinc-200 bg-white px-3 py-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-1 text-xs text-zinc-500">
|
||||
{formatFullTimestamp(data.timestamp)}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="mt-2 flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-zinc-600 dark:text-zinc-400">Total</span>
|
||||
</span>
|
||||
<span className="font-medium">{data.count.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-zinc-600 dark:text-zinc-400">Success</span>
|
||||
</span>
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{data.success.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<span className="text-zinc-600 dark:text-zinc-400">Error</span>
|
||||
</span>
|
||||
<span className="font-medium text-red-600 dark:text-red-400">
|
||||
{data.error.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsVolumeChart({
|
||||
data,
|
||||
loading,
|
||||
onTimeRangeChange,
|
||||
onResetZoom,
|
||||
isZoomed,
|
||||
startTime,
|
||||
endTime,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: LogsVolumeChartProps) {
|
||||
// State for drag selection
|
||||
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
|
||||
const [refAreaRight, setRefAreaRight] = useState<number | null>(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
|
||||
// Transform data for chart, filling in empty buckets for the full time range
|
||||
const chartData = useMemo(() => {
|
||||
// Need bucket_size_seconds and valid time range
|
||||
if (
|
||||
!data?.bucket_size_seconds ||
|
||||
!startTime ||
|
||||
!endTime ||
|
||||
startTime >= endTime
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bucketSizeMs = data.bucket_size_seconds * 1000;
|
||||
|
||||
// Align start time to bucket boundary
|
||||
const minTime =
|
||||
Math.floor((startTime * 1000) / bucketSizeMs) * bucketSizeMs;
|
||||
const maxTime = endTime * 1000;
|
||||
|
||||
// Safety: limit maximum number of buckets to prevent performance issues
|
||||
const maxBuckets = 500;
|
||||
const estimatedBuckets = Math.ceil((maxTime - minTime) / bucketSizeMs);
|
||||
|
||||
if (estimatedBuckets > maxBuckets) {
|
||||
// If too many buckets, just return the original data without filling
|
||||
const result = (data.buckets || []).map((bucket, index) => ({
|
||||
...bucket,
|
||||
index,
|
||||
formattedTime: formatTimestamp(
|
||||
bucket.timestamp,
|
||||
data.bucket_size_seconds,
|
||||
),
|
||||
}));
|
||||
// Ensure at least 2 data points for Recharts
|
||||
if (result.length === 1) {
|
||||
const nextTimestamp = new Date(
|
||||
new Date(result[0].timestamp).getTime() + bucketSizeMs,
|
||||
).toISOString();
|
||||
result.push({
|
||||
timestamp: nextTimestamp,
|
||||
count: 0,
|
||||
success: 0,
|
||||
error: 0,
|
||||
index: 1,
|
||||
formattedTime: formatTimestamp(
|
||||
nextTimestamp,
|
||||
data.bucket_size_seconds,
|
||||
),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// First, create all empty buckets for the time range
|
||||
const filledBuckets: Array<
|
||||
HistogramBucket & { formattedTime: string; index: number }
|
||||
> = [];
|
||||
for (
|
||||
let time = minTime, idx = 0;
|
||||
time < maxTime;
|
||||
time += bucketSizeMs, idx++
|
||||
) {
|
||||
const timestamp = new Date(time).toISOString();
|
||||
filledBuckets.push({
|
||||
timestamp,
|
||||
count: 0,
|
||||
success: 0,
|
||||
error: 0,
|
||||
index: idx,
|
||||
formattedTime: formatTimestamp(timestamp, data.bucket_size_seconds),
|
||||
});
|
||||
}
|
||||
|
||||
// Then, place API buckets at their correct positions using index calculation
|
||||
// This is more robust than exact timestamp matching
|
||||
for (const bucket of data.buckets || []) {
|
||||
const bucketTime = new Date(bucket.timestamp).getTime();
|
||||
// Calculate the index for this bucket based on its offset from minTime
|
||||
const bucketIndex = Math.round((bucketTime - minTime) / bucketSizeMs);
|
||||
|
||||
if (bucketIndex >= 0 && bucketIndex < filledBuckets.length) {
|
||||
filledBuckets[bucketIndex] = {
|
||||
...bucket,
|
||||
index: bucketIndex,
|
||||
formattedTime: formatTimestamp(
|
||||
bucket.timestamp,
|
||||
data.bucket_size_seconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least 2 data points for Recharts
|
||||
if (filledBuckets.length === 1) {
|
||||
const nextTimestamp = new Date(
|
||||
new Date(filledBuckets[0].timestamp).getTime() + bucketSizeMs,
|
||||
).toISOString();
|
||||
filledBuckets.push({
|
||||
timestamp: nextTimestamp,
|
||||
count: 0,
|
||||
success: 0,
|
||||
error: 0,
|
||||
index: 1,
|
||||
formattedTime: formatTimestamp(nextTimestamp, data.bucket_size_seconds),
|
||||
});
|
||||
}
|
||||
|
||||
return filledBuckets;
|
||||
}, [data, startTime, endTime]);
|
||||
|
||||
// Handle mouse down on chart (start selection)
|
||||
const handleMouseDown = useCallback((e: ChartMouseEvent) => {
|
||||
if (typeof e?.activeTooltipIndex === "number") {
|
||||
setRefAreaLeft(e.activeTooltipIndex);
|
||||
setIsSelecting(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle mouse move on chart (during selection)
|
||||
const handleMouseMove = useCallback(
|
||||
(e: ChartMouseEvent) => {
|
||||
if (isSelecting && typeof e?.activeTooltipIndex === "number") {
|
||||
setRefAreaRight(e.activeTooltipIndex);
|
||||
}
|
||||
},
|
||||
[isSelecting],
|
||||
);
|
||||
|
||||
// Handle mouse up on chart (end selection)
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (
|
||||
refAreaLeft === null ||
|
||||
refAreaRight === null ||
|
||||
!data?.bucket_size_seconds ||
|
||||
chartData.length === 0
|
||||
) {
|
||||
setRefAreaLeft(null);
|
||||
setRefAreaRight(null);
|
||||
setIsSelecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the buckets by index
|
||||
const leftBucket = chartData[refAreaLeft];
|
||||
const rightBucket = chartData[refAreaRight];
|
||||
|
||||
if (leftBucket && rightBucket) {
|
||||
const leftTime = new Date(leftBucket.timestamp).getTime() / 1000;
|
||||
const rightTime = new Date(rightBucket.timestamp).getTime() / 1000;
|
||||
|
||||
// Ensure left < right; the end edge is one bucket past the later timestamp
|
||||
const selectionStart = Math.min(leftTime, rightTime);
|
||||
const selectionEnd =
|
||||
Math.max(leftTime, rightTime) + data.bucket_size_seconds;
|
||||
|
||||
// Only trigger if selection spans at least one bucket
|
||||
if (selectionEnd - selectionStart >= data.bucket_size_seconds) {
|
||||
onTimeRangeChange(selectionStart, selectionEnd);
|
||||
}
|
||||
}
|
||||
|
||||
setRefAreaLeft(null);
|
||||
setRefAreaRight(null);
|
||||
setIsSelecting(false);
|
||||
}, [refAreaLeft, refAreaRight, data, chartData, onTimeRangeChange]);
|
||||
|
||||
// Handle click on a bar (zoom into that bucket)
|
||||
const handleBarClick = useCallback(
|
||||
(barData: LogVolumeDataPoint | undefined) => {
|
||||
if (!data || !barData?.timestamp) return;
|
||||
|
||||
const startTime = new Date(barData.timestamp).getTime() / 1000;
|
||||
const endTime = startTime + data.bucket_size_seconds;
|
||||
|
||||
onTimeRangeChange(startTime, endTime);
|
||||
},
|
||||
[data, onTimeRangeChange],
|
||||
);
|
||||
|
||||
// Check if we have valid data for the chart
|
||||
const hasValidData = data && startTime && endTime && chartData.length >= 2;
|
||||
|
||||
return (
|
||||
<Card className="rounded-sm px-2 py-2 shadow-none">
|
||||
<Collapsible open={isOpen} onOpenChange={onOpenChange}>
|
||||
<div className="flex items-center justify-between">
|
||||
<CollapsibleTrigger
|
||||
data-testid="logs-volume-chart-trigger"
|
||||
className="flex items-center gap-2 hover:opacity-80"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`text-muted-foreground h-4 w-4 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Request Volume
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<div className="mr-2 flex items-center gap-4">
|
||||
{isOpen && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-muted-foreground">Success</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<span className="text-muted-foreground">Error</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isZoomed && onResetZoom && (
|
||||
<button
|
||||
data-testid="logs-volume-chart-reset-zoom"
|
||||
onClick={onResetZoom}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset zoom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent className="data-[state=closed]:animate-collapse-up data-[state=open]:animate-collapse-down overflow-hidden">
|
||||
<div className="mt-2 h-32 select-none">
|
||||
{loading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : hasValidData ? (
|
||||
<ChartErrorBoundary
|
||||
resetKey={`${startTime}-${endTime}-${chartData.length}`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 6, right: 4, left: 12, bottom: 0 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
barCategoryGap={1}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-zinc-200 dark:stroke-zinc-700"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
type="number"
|
||||
domain={[-0.5, chartData.length - 0.5]}
|
||||
tick={{ fontSize: 11, className: "fill-zinc-500", dy: 5 }}
|
||||
tickLine={true}
|
||||
axisLine={false}
|
||||
tickFormatter={(idx) =>
|
||||
chartData[Math.round(idx)]?.formattedTime || ""
|
||||
}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, className: "fill-zinc-500" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={40}
|
||||
tickFormatter={(v) => formatRequest(v)}
|
||||
domain={[0, (dataMax: number) => Math.max(dataMax, 5)]}
|
||||
allowDataOverflow={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip />}
|
||||
cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="success"
|
||||
stackId="requests"
|
||||
barSize={30}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.7}
|
||||
radius={[0, 0, 0, 0]}
|
||||
cursor="pointer"
|
||||
onClick={(data) => handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="error"
|
||||
stackId="requests"
|
||||
fill="#ef4444"
|
||||
barSize={30}
|
||||
fillOpacity={0.7}
|
||||
radius={[2, 2, 0, 0]}
|
||||
cursor="pointer"
|
||||
onClick={(data) => handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
|
||||
/>
|
||||
{refAreaLeft !== null &&
|
||||
refAreaRight !== null &&
|
||||
chartData[refAreaLeft] &&
|
||||
chartData[refAreaRight] && (
|
||||
<ReferenceArea
|
||||
x1={refAreaLeft}
|
||||
x2={refAreaRight}
|
||||
strokeOpacity={0.3}
|
||||
fill="#6366f1"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartErrorBoundary>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
167
ui/app/workspace/logs/views/ocrView.tsx
Normal file
167
ui/app/workspace/logs/views/ocrView.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { BifrostOCRResponse, OCRDocument } from "@/lib/types/logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { ChevronLeft, ChevronRight, FileText } from "lucide-react";
|
||||
|
||||
function getImageSrc(b64: string): string {
|
||||
if (b64.startsWith("/9j/")) return `data:image/jpeg;base64,${b64}`;
|
||||
if (b64.startsWith("iVBOR")) return `data:image/png;base64,${b64}`;
|
||||
if (b64.startsWith("UklGR")) return `data:image/webp;base64,${b64}`;
|
||||
if (b64.startsWith("R0lGO")) return `data:image/gif;base64,${b64}`;
|
||||
return `data:image/png;base64,${b64}`;
|
||||
}
|
||||
|
||||
interface OCRViewProps {
|
||||
ocrInput?: OCRDocument;
|
||||
ocrOutput?: BifrostOCRResponse;
|
||||
}
|
||||
|
||||
export default function OCRView({ ocrInput, ocrOutput }: OCRViewProps) {
|
||||
const pages = ocrOutput?.pages ?? [];
|
||||
const totalPages = pages.length;
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalPages === 0) {
|
||||
setCurrentIndex(0);
|
||||
} else {
|
||||
setCurrentIndex((prev) => Math.min(prev, totalPages - 1));
|
||||
}
|
||||
}, [totalPages]);
|
||||
|
||||
const goToPrevious = () => setCurrentIndex((prev) => (prev === 0 ? totalPages - 1 : prev - 1));
|
||||
const goToNext = () => setCurrentIndex((prev) => (prev === totalPages - 1 ? 0 : prev + 1));
|
||||
|
||||
const currentPage = pages[currentIndex] ?? null;
|
||||
const pageImages = currentPage?.images?.filter((img) => img.image_base64) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* OCR Input */}
|
||||
{ocrInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4" />
|
||||
OCR Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">TYPE</div>
|
||||
<div className="font-mono text-xs">{ocrInput.type === "document_url" ? "Document" : "Image"}</div>
|
||||
</div>
|
||||
{(ocrInput.document_url || ocrInput.image_url) && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">
|
||||
{ocrInput.type === "document_url" ? "DOCUMENT URL" : "IMAGE URL"}
|
||||
</div>
|
||||
<div className="font-mono text-xs break-all">{ocrInput.document_url ?? ocrInput.image_url}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OCR Output */}
|
||||
{ocrOutput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4" />
|
||||
OCR Output
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
{ocrOutput.usage_info && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">PAGES PROCESSED</div>
|
||||
<div className="font-mono text-xs">{ocrOutput.usage_info.pages_processed}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">DOCUMENT SIZE</div>
|
||||
<div className="font-mono text-xs">{(ocrOutput.usage_info.doc_size_bytes / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ocrOutput.document_annotation && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">DOCUMENT ANNOTATION</div>
|
||||
<div className="font-mono text-xs">{ocrOutput.document_annotation}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPage && (
|
||||
<>
|
||||
{currentPage.dimensions && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">DIMENSIONS</div>
|
||||
<div className="font-mono text-xs">{currentPage.dimensions.width} × {currentPage.dimensions.height}px</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">DPI</div>
|
||||
<div className="font-mono text-xs">{currentPage.dimensions.dpi}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPage.markdown ? (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">MARKDOWN</div>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight
|
||||
maxHeight={400}
|
||||
wrap
|
||||
code={currentPage.markdown}
|
||||
lang="markdown"
|
||||
readonly
|
||||
options={{
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: "off",
|
||||
alwaysConsumeMouseWheel: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground font-mono text-xs">No text extracted from this page.</div>
|
||||
)}
|
||||
|
||||
{pageImages.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">EXTRACTED IMAGES ({pageImages.length})</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pageImages.map((img) => (
|
||||
<img
|
||||
key={img.id}
|
||||
src={getImageSrc(img.image_base64!)}
|
||||
alt={`Image ${img.id}`}
|
||||
className="max-h-48 max-w-48 rounded border object-contain"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-4">
|
||||
<Button variant="outline" size="sm" onClick={goToPrevious} aria-label="Previous page" title="Previous page" data-testid="ocr-view-pagination-prev-button">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Page {currentIndex + 1} / {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={goToNext} aria-label="Next page" title="Next page" data-testid="ocr-view-pagination-next-button">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
ui/app/workspace/logs/views/pluginLogsView.tsx
Normal file
81
ui/app/workspace/logs/views/pluginLogsView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { PluginLogEntry } from "@/lib/types/logs";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useState } from "react";
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
info: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
warn: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
};
|
||||
|
||||
interface PluginLogsViewProps {
|
||||
pluginLogs: string;
|
||||
}
|
||||
|
||||
export default function PluginLogsView({ pluginLogs }: PluginLogsViewProps) {
|
||||
let parsed: Record<string, PluginLogEntry[]>;
|
||||
try {
|
||||
const raw: unknown = JSON.parse(pluginLogs);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
parsed = Object.fromEntries(Object.entries(raw as Record<string, unknown>).filter(([, value]) => Array.isArray(value))) as Record<
|
||||
string,
|
||||
PluginLogEntry[]
|
||||
>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluginNames = Object.keys(parsed);
|
||||
if (pluginNames.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="py-3 text-sm font-semibold">Plugin Logs</div>
|
||||
<div className="flex flex-col gap-2 pb-3">
|
||||
{pluginNames.map((name) => (
|
||||
<PluginSection key={name} name={name} entries={parsed[name]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSection({ name, entries }: { name: string; entries: PluginLogEntry[] }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const sorted = [...entries].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`plugin-logs-toggle-${name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "")}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="hover:bg-muted/50 flex w-full items-center gap-2 px-4 py-2 text-left text-sm"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<span className="font-medium">{name}</span>
|
||||
<span className="text-muted-foreground text-xs">({entries.length})</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="custom-scrollbar max-h-[300px] overflow-y-auto border-t">
|
||||
{sorted.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 border-b px-4 py-1.5 font-mono text-xs last:border-b-0">
|
||||
<span className="text-muted-foreground shrink-0">{format(new Date(entry.timestamp), "HH:mm:ss.SSS")}</span>
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ${levelColors[entry.level] || levelColors.info}`}
|
||||
>
|
||||
{entry.level}
|
||||
</span>
|
||||
<span className="break-words whitespace-pre-wrap">{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
ui/app/workspace/logs/views/speechView.tsx
Normal file
73
ui/app/workspace/logs/views/speechView.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { BifrostSpeech, SpeechInput } from "@/lib/types/logs";
|
||||
import { AlertCircle, Play, Volume2 } from "lucide-react";
|
||||
import React, { Component } from "react";
|
||||
import AudioPlayer from "./audioPlayer";
|
||||
|
||||
interface SpeechViewProps {
|
||||
speechInput?: SpeechInput;
|
||||
speechOutput?: BifrostSpeech;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Error boundary specifically for audio player errors
|
||||
class AudioErrorBoundary extends Component<{ children: React.ReactNode }, { hasError: boolean; error: Error | null }> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("Audio player error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-sm border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>Failed to load audio player: {this.state.error?.message || "Unknown error"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SpeechView({ speechInput, speechOutput, isStreaming }: SpeechViewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Speech Input */}
|
||||
{speechInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Volume2 className="h-4 w-4" />
|
||||
Speech Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="font-mono text-xs">{speechInput.input}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Speech Output */}
|
||||
{(speechOutput || isStreaming) && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Play className="h-4 w-4" />
|
||||
Speech Output
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<AudioErrorBoundary>
|
||||
<AudioPlayer src={speechOutput?.audio || ""} />
|
||||
</AudioErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
ui/app/workspace/logs/views/transcriptionView.tsx
Normal file
158
ui/app/workspace/logs/views/transcriptionView.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { BifrostTranscribe, TranscriptionInput } from "@/lib/types/logs";
|
||||
import { Clock, FileAudio, Mic } from "lucide-react";
|
||||
import AudioPlayer from "./audioPlayer";
|
||||
|
||||
interface TranscriptionViewProps {
|
||||
transcriptionInput?: TranscriptionInput;
|
||||
transcriptionOutput?: BifrostTranscribe;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export default function TranscriptionView({ transcriptionInput, transcriptionOutput, isStreaming }: TranscriptionViewProps) {
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = (seconds % 60).toFixed(1);
|
||||
return `${mins}:${secs.padStart(4, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Transcription Input */}
|
||||
{transcriptionInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<FileAudio className="h-4 w-4" />
|
||||
Transcription Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">AUDIO FILE</div>
|
||||
{/* Audio Controls */}
|
||||
<AudioPlayer src={transcriptionInput.file} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcription Output */}
|
||||
{(transcriptionOutput || isStreaming) && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Mic className="h-4 w-4" />
|
||||
Transcription Output
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
{!transcriptionOutput && isStreaming ? (
|
||||
<div className="font-mono text-xs">Output was streamed and is not available.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Main Transcription Text */}
|
||||
<div>
|
||||
<div className="font-mono text-xs">{transcriptionOutput?.text}</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
{(transcriptionOutput?.task || transcriptionOutput?.language || transcriptionOutput?.duration) && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{transcriptionOutput?.task && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">TASK</div>
|
||||
<div className="font-mono text-xs">{transcriptionOutput.task}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcriptionOutput?.language && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">DETECTED LANGUAGE</div>
|
||||
<div className="font-mono text-xs">{transcriptionOutput.language}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcriptionOutput?.duration && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">DURATION</div>
|
||||
<div className="font-mono text-xs">{transcriptionOutput.duration.toFixed(1)}s</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Words with Timing */}
|
||||
{transcriptionOutput?.words && transcriptionOutput.words.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">WORD-LEVEL TIMING</div>
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{transcriptionOutput.words.map((word, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs"
|
||||
title={`${formatTime(word.start)} - ${formatTime(word.end)}`}
|
||||
>
|
||||
<span>{word.word}</span>
|
||||
<span className="text-muted-foreground text-xs">{formatTime(word.start)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Segments */}
|
||||
{transcriptionOutput?.segments && transcriptionOutput.segments.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">SEGMENTS</div>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto">
|
||||
{transcriptionOutput.segments.map((segment) => (
|
||||
<div key={segment.id} className="rounded border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Segment {segment.id}
|
||||
</Badge>
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(segment.start)} - {formatTime(segment.end)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm">{segment.text}</div>
|
||||
<div className="text-muted-foreground mt-2 flex gap-4 text-xs">
|
||||
<span>Avg LogProb: {segment.avg_logprob.toFixed(3)}</span>
|
||||
<span>No Speech: {(segment.no_speech_prob * 100).toFixed(1)}%</span>
|
||||
<span>Temp: {segment.temperature.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Probabilities */}
|
||||
{transcriptionOutput?.logprobs && transcriptionOutput.logprobs.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">LOG PROBABILITIES</div>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={200}
|
||||
wrap={true}
|
||||
code={JSON.stringify(transcriptionOutput.logprobs, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{
|
||||
scrollBeyondLastLine: false,
|
||||
collapsibleBlocks: true,
|
||||
lineNumbers: "off",
|
||||
alwaysConsumeMouseWheel: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
ui/app/workspace/logs/views/videoView.tsx
Normal file
155
ui/app/workspace/logs/views/videoView.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ExternalLink, Video } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BifrostVideoDownloadOutput, BifrostVideoGenerationOutput, BifrostVideoListOutput } from "@/lib/types/logs";
|
||||
|
||||
import CollapsibleBox from "./collapsibleBox";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
|
||||
interface VideoGenerationInput {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
type VideoOutput = BifrostVideoGenerationOutput | BifrostVideoDownloadOutput;
|
||||
|
||||
interface VideoViewProps {
|
||||
videoInput?: VideoGenerationInput;
|
||||
videoOutput?: VideoOutput;
|
||||
videoListOutput?: BifrostVideoListOutput;
|
||||
requestType?: string;
|
||||
}
|
||||
|
||||
function getMethodTypeLabel(requestType?: string): string {
|
||||
if (!requestType) return "Video";
|
||||
const normalized = requestType.toLowerCase();
|
||||
if (normalized.includes("video_download")) return "Video Download";
|
||||
if (normalized.includes("video_retrieve")) return "Video Retrieve";
|
||||
if (normalized.includes("video_generation")) return "Video Generation";
|
||||
if (normalized.includes("video_list")) return "Video List";
|
||||
return "Video";
|
||||
}
|
||||
|
||||
export default function VideoView({ videoInput, videoOutput, videoListOutput, requestType }: VideoViewProps) {
|
||||
const methodTypeLabel = getMethodTypeLabel(requestType);
|
||||
const isDownload = requestType?.toLowerCase().includes("video_download");
|
||||
const downloadOutput = isDownload && videoOutput ? (videoOutput as BifrostVideoDownloadOutput) : null;
|
||||
const generationOutput = !isDownload && videoOutput ? (videoOutput as BifrostVideoGenerationOutput) : null;
|
||||
const outputURL = generationOutput?.videos?.[0]?.url;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{videoInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Video className="h-4 w-4" />
|
||||
{methodTypeLabel} Input
|
||||
</div>
|
||||
<div className="space-y-2 p-6">
|
||||
<div className="text-muted-foreground text-xs font-medium">PROMPT</div>
|
||||
<div className="font-mono text-xs">{videoInput.prompt}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoOutput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Video className="h-4 w-4" />
|
||||
{methodTypeLabel} Output
|
||||
</div>
|
||||
<div className="space-y-3 p-6">
|
||||
{downloadOutput ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{downloadOutput.video_id && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">VIDEO ID</div>
|
||||
<div className="font-mono text-xs break-all">{downloadOutput.video_id}</div>
|
||||
</div>
|
||||
)}
|
||||
{downloadOutput.content_type && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">CONTENT TYPE</div>
|
||||
<div className="font-mono text-xs">{downloadOutput.content_type}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">Video content was successfully downloaded (content is not stored in logs)</p>
|
||||
</>
|
||||
) : generationOutput ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{generationOutput.status && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">STATUS</div>
|
||||
<Badge variant="secondary" className="uppercase">
|
||||
{generationOutput.status}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{generationOutput.progress !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">PROGRESS</div>
|
||||
<div className="font-mono text-xs">{generationOutput.progress}%</div>
|
||||
</div>
|
||||
)}
|
||||
{generationOutput.id && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">VIDEO ID</div>
|
||||
<div className="font-mono text-xs break-all">{generationOutput.id}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generationOutput.error && (generationOutput.error.message || generationOutput.error.code) && (
|
||||
<div className="flex items-start gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground font-medium">Error from provider</div>
|
||||
{generationOutput.error.code && <div className="font-medium">{generationOutput.error.code}</div>}
|
||||
{generationOutput.error.message && <div className="text-muted-foreground">{generationOutput.error.message}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outputURL && (
|
||||
<div className="space-y-2">
|
||||
<video className="w-full rounded-sm border bg-black" controls preload="metadata" src={outputURL}>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
<a
|
||||
href={outputURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs underline"
|
||||
>
|
||||
Open video URL
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoListOutput && (
|
||||
<CollapsibleBox
|
||||
title={`Video List Output (${videoListOutput.data?.length ?? 0})`}
|
||||
onCopy={() => JSON.stringify(videoListOutput, null, 2)}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={450}
|
||||
wrap={true}
|
||||
code={JSON.stringify(videoListOutput.data, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user