Add SSE response summary helpers (#466)

Co-authored-by: Gregory Schier <gschier1990@gmail.com>
This commit is contained in:
baofeidyz
2026-07-02 03:33:03 +08:00
committed by GitHub
parent 12562aa076
commit 24e578db5f
13 changed files with 795 additions and 109 deletions
@@ -282,6 +282,22 @@ function EditorInner({
[disableTabIndent],
);
// Update read-only
const readOnlyCompartment = useRef(new Compartment());
useEffect(
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
const effects = readOnlyCompartment.current.reconfigure(next);
cm.current?.view.dispatch({ effects });
},
[readOnly],
);
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => {
@@ -394,9 +410,9 @@ function EditorInner({
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
...getExtensions({
container,
readOnly,
singleLine,
hideGutter,
stateKey,
@@ -553,7 +569,6 @@ function EditorInner({
function getExtensions({
stateKey,
container,
readOnly,
singleLine,
hideGutter,
onChange,
@@ -562,7 +577,7 @@ function getExtensions({
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & {
}: Pick<EditorProps, "singleLine" | "hideGutter"> & {
stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>;
@@ -612,7 +627,6 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ //
// Things that must be last //
@@ -9,6 +9,8 @@ import { CopyIconButton } from "../CopyIconButton";
import { AutoScroller } from "./AutoScroller";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import type { SelectProps } from "./Select";
import { Select } from "./Select";
import { Separator } from "./Separator";
interface EventViewerProps<T> {
@@ -151,7 +153,7 @@ export function EventViewer<T>({
layout="vertical"
storageKey={splitLayoutStorageKey}
defaultRatio={defaultRatio}
minHeightPx={10}
minHeightPx={72}
firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />}
@@ -202,23 +204,38 @@ export function EventViewer<T>({
);
}
export interface EventDetailAction {
/** Unique key for React */
key: string;
/** Button label */
label: string;
/** Optional icon */
icon?: ReactNode;
/** Click handler */
onClick: () => void;
}
export type EventDetailAction =
| {
type?: "button";
/** Unique key for React */
key: string;
/** Button label */
label: string;
/** Optional icon */
icon?: ReactNode;
/** Click handler */
onClick: () => void;
}
| {
type: "select";
/** Unique key for React */
key: string;
/** Select label */
label: string;
/** Selected value */
value: string;
/** Select options */
options: SelectProps<string>["options"];
/** Change handler */
onChange: (value: string) => void;
};
interface EventDetailHeaderProps {
title: string;
prefix?: ReactNode;
timestamp?: string;
actions?: EventDetailAction[];
copyText?: string;
copyText?: string | (() => Promise<string | null>);
onClose?: () => void;
}
@@ -239,40 +256,56 @@ export function EventDetailHeader({
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack>
<HStack space={2} className="items-center">
{actions?.map((action) => (
<Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
))}
{actions?.map((action) =>
action.type === "select" ? (
<div key={action.key} className="w-32">
<Select
name={action.key}
label={action.label}
hideLabel
size="xs"
value={action.value}
options={action.options}
onChange={action.onChange}
/>
</div>
) : (
<Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
),
)}
{copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)}
{formattedTime && (
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)}
<div
className={classNames(
copyText != null ||
formattedTime ||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
)}
>
<IconButton
color="custom"
className="text-text-subtle -mr-3"
size="xs"
icon="x"
title="Close event panel"
onClick={onClose}
/>
</div>
{onClose != null && (
<div
className={classNames(
copyText != null ||
formattedTime ||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
)}
>
<IconButton
color="custom"
className="text-text-subtle -mr-3"
size="xs"
icon="x"
title="Close event panel"
onClick={onClose}
/>
</div>
)}
</HStack>
</div>
);
@@ -1,21 +1,38 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import type { ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useMemo, useState } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useFormatText } from "../../hooks/useFormatText";
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
import {
sseSummaryResultKeyPathAutocomplete,
useSseSummaryResultKeyPath,
} from "../../hooks/useSseSummaryResultKeyPath";
import { isJSON } from "../../lib/contentType";
import { EmptyStateText } from "../EmptyStateText";
import { Markdown } from "../Markdown";
import { Button } from "../core/Button";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
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 { IconButton } from "../core/IconButton";
import { IconTooltip } from "../core/IconTooltip";
import { Input } from "../core/Input";
import { Select } from "../core/Select";
interface Props {
response: HttpResponse;
}
const DEFAULT_EXTRACTED_TEXT_RATIO = 0.28;
export function EventStreamViewer({ response }: Props) {
return (
<Fragment
@@ -29,64 +46,316 @@ export function EventStreamViewer({ response }: Props) {
function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const filterEventPreviewsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_filter_event_previews", response.requestId],
fallback: false,
});
const applyToDetailsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_apply_to_details", response.requestId],
fallback: false,
});
const renderMarkdownSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_render_markdown", response.requestId],
fallback: false,
});
const summarySettings = useSseSummaryResultKeyPath({ response });
const events = useResponseBodyEventSource(response);
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
const showExtractedText = summarySettings.resultKeyPath != null;
const showResultKeyPathWarning =
showExtractedText &&
summary.data != null &&
summary.data.fragmentCount === 0 &&
!summary.isFetching &&
summary.error == null;
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
const settingsItems = useMemo<DropdownItem[]>(
() => [
{
label: "Apply to Previews",
keepOpenOnSelect: true,
onSelect: () => filterEventPreviewsSetting.set(filterEventPreviewsSetting.value !== true),
leftSlot: (
<Icon
icon={
filterEventPreviewsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
{
label: "Apply to Details",
keepOpenOnSelect: true,
onSelect: () => applyToDetailsSetting.set(applyToDetailsSetting.value !== true),
leftSlot: (
<Icon
icon={
applyToDetailsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
],
[
applyToDetailsSetting,
filterEventPreviewsSetting,
],
);
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)]">
<HStack space={2} alignItems="center" className="pt-1 pb-1 border-b border-border-subtle">
<div className={classNames(summarySettings.enabled ? "w-44 shrink-0" : "min-w-40 flex-1")}>
<Select
name={`sse-summary-result-key-path-enabled::${response.requestId}`}
label="Extracted text"
hideLabel
size="xs"
value={summarySettings.enabled ? "jsonpath" : "off"}
options={[
{ label: "Full events", value: "off" },
{ label: "JSONPath", value: "jsonpath" },
]}
onChange={(value) => summarySettings.setEnabled(value === "jsonpath")}
/>
</div>
{summarySettings.enabled && (
<>
<div className="min-w-40 flex-1">
<Input
label="Result JSON path"
hideLabel
size="xs"
autocomplete={sseSummaryResultKeyPathAutocomplete}
defaultValue={summarySettings.resultKeyPathInputValue}
forceUpdateKey={`${response.requestId}:${summarySettings.inferredResultKeyPath ?? ""}`}
placeholder="$.choices[0].delta.content"
rightSlot={
showResultKeyPathWarning ? (
<div className="flex items-center px-2">
<IconTooltip
tabIndex={-1}
icon="alert_triangle"
iconColor="notice"
content="No text fragments matched this JSONPath."
/>
</div>
) : null
}
stateKey={`sse-summary-result-key-path::${response.requestId}`}
tint={showResultKeyPathWarning ? "notice" : undefined}
onChange={summarySettings.setResultKeyPath}
/>
</div>
<Dropdown items={settingsItems}>
<IconButton
size="xs"
variant="border"
icon="settings"
title="Extracted text settings"
/>
</Dropdown>
</>
)}
</HStack>
<SplitLayout
layout="vertical"
storageKey={`sse_extracted_text::${response.requestId}`}
defaultRatio={DEFAULT_EXTRACTED_TEXT_RATIO}
minHeightPx={72}
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
firstSlot={({ style }) => (
<div style={style} className="min-h-0">
<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">
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
applyJsonPath={applyToDetails}
resultKeyPath={summarySettings.resultKeyPath}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</div>
)}
secondSlot={
showExtractedText
? ({ style }) => (
<SseSummaryFooter
style={style}
error={summary.error ? String(summary.error) : null}
isLoading={summary.isLoading}
onRenderMarkdownChange={renderMarkdownSetting.set}
renderMarkdown={renderMarkdown}
resultKeyPath={summarySettings.resultKeyPath ?? ""}
summary={summary.data?.summary ?? ""}
fragmentCount={summary.data?.fragmentCount ?? 0}
/>
)
: null
}
/>
</div>
);
}
function SseSummaryFooter({
error,
fragmentCount,
isLoading,
onRenderMarkdownChange,
renderMarkdown,
resultKeyPath,
style,
summary,
}: {
error: string | null;
fragmentCount: number;
isLoading: boolean;
onRenderMarkdownChange: (renderMarkdown: boolean) => void;
renderMarkdown: boolean;
resultKeyPath: string;
style: CSSProperties;
summary: string;
}) {
const hasSummary = fragmentCount > 0;
const actions = useMemo(
() => [
{
key: "sse-summary-format",
label: "Extracted text format",
type: "select" as const,
value: renderMarkdown ? "markdown" : "text",
options: [
{ label: "Text", value: "text" },
{ label: "Markdown", value: "markdown" },
],
onChange: (value: string) => onRenderMarkdownChange(value === "markdown"),
},
],
[onRenderMarkdownChange, renderMarkdown],
);
return (
<div
style={style}
className="min-h-0 overflow-hidden border-t border-border-subtle bg-surface grid grid-rows-[auto_minmax(0,1fr)]"
>
<div className="pt-2">
<EventDetailHeader
actions={actions}
title="Extracted Text"
copyText={hasSummary ? summary : undefined}
/>
</div>
<div
className={classNames(
"min-h-0 py-2 overflow-auto",
(error != null || isLoading || (hasSummary && !renderMarkdown)) && "text-xs",
)}
>
{error != null ? (
<span className="text-danger">{error}</span>
) : isLoading ? (
<span className="italic text-text-subtlest">Loading extracted text...</span>
) : hasSummary ? (
renderMarkdown ? (
<div className="min-h-0">
<Markdown className="select-auto cursor-auto">{summary}</Markdown>
</div>
) : (
<pre className="font-mono whitespace-pre-wrap break-words select-auto cursor-auto">
{summary}
</pre>
)
) : (
<EmptyStateText className="gap-1.5">
No fragments for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
</EmptyStateText>
)}
</div>
</div>
);
}
function getEventPreview(
event: ServerSentEvent,
resultKeyPath: string | null,
filterEventPreview: boolean,
): string {
if (filterEventPreview && resultKeyPath != null) {
return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000);
}
return event.data.slice(0, 1000);
}
function EventDetail({
applyJsonPath,
event,
index,
resultKeyPath,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
applyJsonPath: boolean;
event: ServerSentEvent;
index: number;
resultKeyPath: string | null;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const detailText = useMemo(
() =>
applyJsonPath && resultKeyPath != null
? (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data)
: event.data,
[applyJsonPath, event.data, resultKeyPath],
);
const language = useMemo<"text" | "json">(() => {
if (!event?.data) return "text";
return isJSON(event?.data) ? "json" : "text";
}, [event?.data]);
if (!detailText) return "text";
return isJSON(detailText) ? "json" : "text";
}, [detailText]);
return (
<div className="flex flex-col h-full">
@@ -95,7 +364,7 @@ function EventDetail({
prefix={<EventLabels event={event} index={index} />}
onClose={onClose}
/>
{!showLarge && event.data.length > 1000 * 1000 ? (
{!showLarge && detailText.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
@@ -117,7 +386,7 @@ function EventDetail({
</div>
</VStack>
) : (
<FormattedEditor language={language} text={event.data} />
<FormattedEditor language={language} text={detailText} />
)}
</div>
);
@@ -142,14 +411,17 @@ function EventLabels({
}) {
return (
<HStack space={1.5} alignItems="center" className={className}>
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
{event.id ?? index}
</InlineCode>
{event.eventType && (
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
{event.eventType}
</InlineCode>
)}
<EventLabel isActive={isActive}>{event.id ?? index}</EventLabel>
{event.eventType && <EventLabel isActive={isActive}>{event.eventType}</EventLabel>}
</HStack>
);
}
function EventLabel({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
return (
<InlineCode className={classNames("py-0", isActive && "relative overflow-hidden")}>
{isActive && <span className="absolute inset-0 bg-text opacity-5 pointer-events-none" />}
<span className="relative">{children}</span>
</InlineCode>
);
}
@@ -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),
});
}
@@ -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 | null) {
return useQuery<SseSummary>({
enabled: resultKeyPath != null,
queryKey: [
"response-body-sse-summary",
response.id,
response.updatedAt,
response.contentLength,
resultKeyPath,
],
queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""),
});
}
@@ -0,0 +1,98 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import type { GenericCompletionConfig } from "../components/core/Editor/genericCompletion";
import { useKeyValue } from "./useKeyValue";
const OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH = "$.choices[0].delta.content";
const OPENAI_RESPONSES_RESULT_KEY_PATH = "$.delta";
const ANTHROPIC_RESULT_KEY_PATH = "$.delta.text";
const GOOGLE_RESULT_KEY_PATH = "$.candidates[0].content.parts[0].text";
const sseSummaryResultKeyPathOptions: GenericCompletionOption[] = [
{
label: OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH,
detail: "ChatGPT (OpenAI)",
type: "constant",
boost: 1,
},
{
label: OPENAI_RESPONSES_RESULT_KEY_PATH,
detail: "Responses (OpenAI)",
type: "constant",
boost: 1,
},
{
label: ANTHROPIC_RESULT_KEY_PATH,
detail: "Claude (Anthropic)",
type: "constant",
boost: 1,
},
{
label: GOOGLE_RESULT_KEY_PATH,
detail: "Gemini (Google)",
type: "constant",
boost: 1,
},
];
export const sseSummaryResultKeyPathAutocomplete: GenericCompletionConfig = {
minMatch: 0,
options: sseSummaryResultKeyPathOptions,
};
export function useSseSummaryResultKeyPath({ response }: { response: HttpResponse }) {
const storedResultKeyPath = useKeyValue<string | null>({
namespace: "no_sync",
key: ["sse_summary_result_key_path", response.requestId],
fallback: null,
});
const enabled = useKeyValue<boolean | null>({
namespace: "no_sync",
key: ["sse_summary_result_key_path_enabled", response.requestId],
fallback: null,
});
const inferredResultKeyPath = useMemo(() => inferSseSummaryResultKeyPath(response), [response.url]);
const resultKeyPath = storedResultKeyPath.value ?? inferredResultKeyPath;
const trimmedResultKeyPath = resultKeyPath?.trim() ?? "";
const isEnabled = enabled.value ?? inferredResultKeyPath != null;
return {
enabled: isEnabled,
inferredResultKeyPath,
resultKeyPath: isEnabled && trimmedResultKeyPath.length > 0 ? trimmedResultKeyPath : null,
resultKeyPathInputValue: resultKeyPath ?? "",
setEnabled: enabled.set,
setResultKeyPath: storedResultKeyPath.set,
};
}
function inferSseSummaryResultKeyPath(response: HttpResponse): string | null {
let url: URL;
try {
url = new URL(response.url);
} catch {
return null;
}
const hostname = url.hostname.toLowerCase();
const pathname = url.pathname.toLowerCase();
if (hostname === "api.openai.com" && pathname === "/v1/chat/completions") {
return OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH;
}
if (hostname === "api.openai.com" && pathname === "/v1/responses") {
return OPENAI_RESPONSES_RESULT_KEY_PATH;
}
if (hostname === "api.anthropic.com" && pathname === "/v1/messages") {
return ANTHROPIC_RESULT_KEY_PATH;
}
if (
hostname === "generativelanguage.googleapis.com" &&
pathname.includes(":streamgeneratecontent")
) {
return GOOGLE_RESULT_KEY_PATH;
}
return null;
}
+32 -4
View File
@@ -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({
@@ -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(