Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 182 additions & 32 deletions apps/yaak-client/components/responseViewers/EventStreamViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,34 @@ import type { HttpResponse } from "@yaakapp-internal/models";
import type { ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { Fragment, useMemo, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { Fragment, useCallback, useMemo, useState } from "react";
import { useLocalStorage } from "react-use";
import { useFormatText } from "../../hooks/useFormatText";
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
import {
sseSummaryProviderOptions,
useSseSummaryResultKeyPath,
} from "../../hooks/useSseSummaryResultKeyPath";
import { isJSON } from "../../lib/contentType";
import { Button } from "../core/Button";
import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
import { EventViewerRow } from "../core/EventViewerRow";
import { PlainInput } from "../core/PlainInput";
import { Select } from "../core/Select";
import { Separator } from "../core/Separator";

interface Props {
response: HttpResponse;
}

const DEFAULT_SUMMARY_HEIGHT = 160;
const MIN_SUMMARY_HEIGHT = 72;
const MAX_SUMMARY_HEIGHT = 480;

export function EventStreamViewer({ response }: Props) {
return (
<Fragment
Expand All @@ -29,43 +43,179 @@ export function EventStreamViewer({ response }: Props) {
function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const summarySettings = useSseSummaryResultKeyPath({
requestId: response.requestId,
workspaceId: response.workspaceId,
});
const [summaryHeight, setSummaryHeight] = useLocalStorage<number>(
`sse_summary_height::${response.requestId}`,
DEFAULT_SUMMARY_HEIGHT,
);
const providerSelectOptions = useMemo(
() =>
sseSummaryProviderOptions.map((option) => ({
label: option.label,
value: option.value,
})),
[],
);
const events = useResponseBodyEventSource(response);
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
const isCustomProvider = summarySettings.provider === "custom";

return (
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutStorageKey="sse_events"
defaultRatio={0.4}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
<div className="h-full min-h-0 grid grid-rows-[auto_minmax(0,1fr)_auto]">
<HStack space={2} alignItems="center" className="px-2 py-1 border-b border-border-subtle">
<div className="w-60 max-w-full shrink-0">
<Select
name="sse-summary-provider"
label="Summary provider"
hideLabel
size="xs"
value={summarySettings.provider}
options={providerSelectOptions}
onChange={summarySettings.setProvider}
/>
</div>
<div className="min-w-40 flex-1">
<PlainInput
label="Result JSON path"
hideLabel
size="xs"
defaultValue={summarySettings.resultKeyPath}
disabled={!isCustomProvider || summarySettings.isLoading}
forceUpdateKey={`${response.requestId}:${summarySettings.provider}:${summarySettings.resultKeyPath}`}
placeholder="$.choices[0].delta.content"
onChange={(keyPath) => {
if (isCustomProvider) {
void summarySettings.setCustomResultKeyPath(keyPath);
}
}}
/>
</div>
<span className="min-w-0 max-w-96 text-xs text-text-subtlest font-mono truncate">
{summarySettings.resultKeyPath}
</span>
</HStack>
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutStorageKey="sse_events"
defaultRatio={0.4}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
<SseSummaryFooter
error={summary.error ? String(summary.error) : null}
height={summaryHeight ?? DEFAULT_SUMMARY_HEIGHT}
isLoading={summary.isLoading}
onHeightChange={setSummaryHeight}
resultKeyPath={summarySettings.resultKeyPath}
summary={summary.data?.summary ?? ""}
fragmentCount={summary.data?.fragmentCount ?? 0}
/>
</div>
);
}

function SseSummaryFooter({
error,
fragmentCount,
height,
isLoading,
onHeightChange,
resultKeyPath,
summary,
}: {
error: string | null;
fragmentCount: number;
height: number;
isLoading: boolean;
onHeightChange: (height: number) => void;
resultKeyPath: string;
summary: string;
}) {
const hasSummary = fragmentCount > 0;
const handleResizeStart = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = height;

const handlePointerMove = (moveEvent: PointerEvent) => {
onHeightChange(clampSummaryHeight(startHeight + startY - moveEvent.clientY));
};

const handlePointerUp = () => {
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
};

document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
},
[height, onHeightChange],
);

return (
<div
className="min-h-0 border-t border-border-subtle bg-surface grid grid-rows-[auto_auto_minmax(0,1fr)]"
style={{ height: clampSummaryHeight(height) }}
>
<div
role="separator"
aria-label="Resize summary"
aria-orientation="horizontal"
className="h-1.5 cursor-ns-resize hover:bg-surface-highlight active:bg-surface-highlight"
onPointerDown={handleResizeStart}
/>
<div className="px-2 pt-1">
<Separator>Summary</Separator>
</div>
<div className="px-3 py-2 overflow-auto text-xs">
{error != null ? (
<span className="text-danger">{error}</span>
) : isLoading ? (
<span className="italic text-text-subtlest">Loading summary...</span>
) : hasSummary ? (
<pre className="font-mono whitespace-pre-wrap break-words select-text">{summary}</pre>
) : (
<span className="italic text-text-subtlest">
No summary fragments found for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
</span>
)}
</div>
</div>
);
}

function clampSummaryHeight(height: number): number {
return Math.max(MIN_SUMMARY_HEIGHT, Math.min(MAX_SUMMARY_HEIGHT, height));
}

function EventDetail({
event,
index,
Expand Down
7 changes: 6 additions & 1 deletion apps/yaak-client/hooks/useResponseBodyEventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { getResponseBodyEventSource } from "../lib/responseBody";
export function useResponseBodyEventSource(response: HttpResponse) {
return useQuery<ServerSentEvent[]>({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ["response-body-event-source", response.id, response.contentLength],
queryKey: [
"response-body-event-source",
response.id,
response.updatedAt,
response.contentLength,
],
queryFn: () => getResponseBodyEventSource(response),
});
}
18 changes: 18 additions & 0 deletions apps/yaak-client/hooks/useResponseBodySseSummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { SseSummary } from "@yaakapp-internal/sse";
import { getResponseBodySseSummary } from "../lib/responseBody";

export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string) {
return useQuery<SseSummary>({
placeholderData: (prev) => prev,
queryKey: [
"response-body-sse-summary",
response.id,
response.updatedAt,
response.contentLength,
resultKeyPath,
],
queryFn: () => getResponseBodySseSummary(response, resultKeyPath),
});
}
64 changes: 64 additions & 0 deletions apps/yaak-client/hooks/useSseSummaryResultKeyPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useLocalStorage } from "react-use";
import { useKeyValue } from "./useKeyValue";

export const sseSummaryProviderOptions = [
{
label: "ChatGPT (OpenAI)",
resultKeyPath: "$.choices[0].delta.content",
value: "openai",
},
{
label: "Claude (Anthropic)",
resultKeyPath: "$.delta.text",
value: "anthropic",
},
{
label: "Gemini (Google)",
resultKeyPath: "$.candidates[0].content.parts[0].text",
value: "google",
},
{
label: "Custom",
resultKeyPath: null,
value: "custom",
},
] as const;

export type SseSummaryProvider = (typeof sseSummaryProviderOptions)[number]["value"];

const DEFAULT_SSE_SUMMARY_PROVIDER = "openai";
const DEFAULT_CUSTOM_SSE_SUMMARY_RESULT_KEY_PATH = "result";

export function useSseSummaryResultKeyPath({
requestId,
workspaceId,
}: {
requestId?: string;
workspaceId?: string;
}) {
const [rawProvider, setProvider] = useLocalStorage<SseSummaryProvider>(
`sse_summary_provider::${requestId}`,
DEFAULT_SSE_SUMMARY_PROVIDER,
);
const customKeyPath = useKeyValue<string>({
key: ["sse_summary_custom_result_key_path", workspaceId ?? "n/a"],
fallback: DEFAULT_CUSTOM_SSE_SUMMARY_RESULT_KEY_PATH,
});
const provider = isSseSummaryProvider(rawProvider) ? rawProvider : DEFAULT_SSE_SUMMARY_PROVIDER;
const preset = sseSummaryProviderOptions.find((option) => option.value === provider);
const resultKeyPath =
preset?.resultKeyPath ?? customKeyPath.value ?? DEFAULT_CUSTOM_SSE_SUMMARY_RESULT_KEY_PATH;

return {
customResultKeyPath: customKeyPath.value ?? DEFAULT_CUSTOM_SSE_SUMMARY_RESULT_KEY_PATH,
isLoading: customKeyPath.isLoading,
provider,
resultKeyPath,
setCustomResultKeyPath: customKeyPath.set,
setProvider,
};
}

function isSseSummaryProvider(value: unknown): value is SseSummaryProvider {
return sseSummaryProviderOptions.some((option) => option.value === value);
}
36 changes: 32 additions & 4 deletions apps/yaak-client/lib/responseBody.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { readFile } from "@tauri-apps/plugin-fs";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { FilterResponse } from "@yaakapp-internal/plugins";
import type { ServerSentEvent } from "@yaakapp-internal/sse";
import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
import { invokeCmd } from "./tauri";

export async function getResponseBodyText({
Expand All @@ -27,9 +28,36 @@ export async function getResponseBodyEventSource(
response: HttpResponse,
): Promise<ServerSentEvent[]> {
if (!response.bodyPath) return [];
return invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
filePath: response.bodyPath,
});
try {
const events = await invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
filePath: response.bodyPath,
});
if (events.length > 0) {
return events;
}
} catch {
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
}

const bytes = await readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
data,
eventType: "",
id: String(index),
retry: null,
}));
}

export async function getResponseBodySseSummary(
response: HttpResponse,
resultKeyPath: string,
): Promise<SseSummary> {
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };

const bytes = await readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return computeSseSummary(text, resultKeyPath);
}

export async function getResponseBodyBytes(
Expand Down
1 change: 1 addition & 0 deletions crates/yaak-sse/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./bindings/sse";
export * from "./summary";
Loading