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], [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( const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => { async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => { const show = () => {
@@ -394,9 +410,9 @@ function EditorInner({
keymapCompartment.current.of( keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
), ),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
...getExtensions({ ...getExtensions({
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
stateKey, stateKey,
@@ -553,7 +569,6 @@ function EditorInner({
function getExtensions({ function getExtensions({
stateKey, stateKey,
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
onChange, onChange,
@@ -562,7 +577,7 @@ function getExtensions({
onFocus, onFocus,
onBlur, onBlur,
onKeyDown, onKeyDown,
}: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & { }: Pick<EditorProps, "singleLine" | "hideGutter"> & {
stateKey: EditorProps["stateKey"]; stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null; container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>; onChange: RefObject<EditorProps["onChange"]>;
@@ -612,7 +627,6 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []), ...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []), ...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ // // ------------------------ //
// Things that must be last // // Things that must be last //
@@ -9,6 +9,8 @@ import { CopyIconButton } from "../CopyIconButton";
import { AutoScroller } from "./AutoScroller"; import { AutoScroller } from "./AutoScroller";
import { Button } from "./Button"; import { Button } from "./Button";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import type { SelectProps } from "./Select";
import { Select } from "./Select";
import { Separator } from "./Separator"; import { Separator } from "./Separator";
interface EventViewerProps<T> { interface EventViewerProps<T> {
@@ -151,7 +153,7 @@ export function EventViewer<T>({
layout="vertical" layout="vertical"
storageKey={splitLayoutStorageKey} storageKey={splitLayoutStorageKey}
defaultRatio={defaultRatio} defaultRatio={defaultRatio}
minHeightPx={10} minHeightPx={72}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />} {header ?? <span aria-hidden />}
@@ -202,23 +204,38 @@ export function EventViewer<T>({
); );
} }
export interface EventDetailAction { export type EventDetailAction =
/** Unique key for React */ | {
key: string; type?: "button";
/** Button label */ /** Unique key for React */
label: string; key: string;
/** Optional icon */ /** Button label */
icon?: ReactNode; label: string;
/** Click handler */ /** Optional icon */
onClick: () => void; 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 { interface EventDetailHeaderProps {
title: string; title: string;
prefix?: ReactNode; prefix?: ReactNode;
timestamp?: string; timestamp?: string;
actions?: EventDetailAction[]; actions?: EventDetailAction[];
copyText?: string; copyText?: string | (() => Promise<string | null>);
onClose?: () => void; onClose?: () => void;
} }
@@ -239,40 +256,56 @@ export function EventDetailHeader({
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3> <h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack> </HStack>
<HStack space={2} className="items-center"> <HStack space={2} className="items-center">
{actions?.map((action) => ( {actions?.map((action) =>
<Button action.type === "select" ? (
key={action.key} <div key={action.key} className="w-32">
type="button" <Select
variant="border" name={action.key}
size="xs" label={action.label}
onClick={action.onClick} hideLabel
> size="xs"
{action.icon} value={action.value}
{action.label} options={action.options}
</Button> onChange={action.onChange}
))} />
</div>
) : (
<Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
),
)}
{copyText != null && ( {copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" /> <CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)} )}
{formattedTime && ( {formattedTime && (
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span> <span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)} )}
<div {onClose != null && (
className={classNames( <div
copyText != null || className={classNames(
formattedTime || copyText != null ||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"), formattedTime ||
)} ((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
> )}
<IconButton >
color="custom" <IconButton
className="text-text-subtle -mr-3" color="custom"
size="xs" className="text-text-subtle -mr-3"
icon="x" size="xs"
title="Close event panel" icon="x"
onClick={onClose} title="Close event panel"
/> onClick={onClose}
</div> />
</div>
)}
</HStack> </HStack>
</div> </div>
); );
@@ -1,21 +1,38 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import type { ServerSentEvent } from "@yaakapp-internal/sse"; import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useFormatText } from "../../hooks/useFormatText"; import { useFormatText } from "../../hooks/useFormatText";
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource"; import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
import {
sseSummaryResultKeyPathAutocomplete,
useSseSummaryResultKeyPath,
} from "../../hooks/useSseSummaryResultKeyPath";
import { isJSON } from "../../lib/contentType"; import { isJSON } from "../../lib/contentType";
import { EmptyStateText } from "../EmptyStateText";
import { Markdown } from "../Markdown";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor"; import { Editor } from "../core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "../core/EventViewer"; import { EventDetailHeader, EventViewer } from "../core/EventViewer";
import { EventViewerRow } from "../core/EventViewerRow"; 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 { interface Props {
response: HttpResponse; response: HttpResponse;
} }
const DEFAULT_EXTRACTED_TEXT_RATIO = 0.28;
export function EventStreamViewer({ response }: Props) { export function EventStreamViewer({ response }: Props) {
return ( return (
<Fragment <Fragment
@@ -29,64 +46,316 @@ export function EventStreamViewer({ response }: Props) {
function ActualEventStreamViewer({ response }: Props) { function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false); const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = 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 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 ( return (
<EventViewer <div className="h-full min-h-0 grid grid-rows-[auto_minmax(0,1fr)]">
events={events.data ?? []} <HStack space={2} alignItems="center" className="pt-1 pb-1 border-b border-border-subtle">
getEventKey={(_, index) => String(index)} <div className={classNames(summarySettings.enabled ? "w-44 shrink-0" : "min-w-40 flex-1")}>
error={events.error ? String(events.error) : null} <Select
splitLayoutStorageKey="sse_events" name={`sse-summary-result-key-path-enabled::${response.requestId}`}
defaultRatio={0.4} label="Extracted text"
renderRow={({ event, index, isActive, onClick }) => ( hideLabel
<EventViewerRow size="xs"
isActive={isActive} value={summarySettings.enabled ? "jsonpath" : "off"}
onClick={onClick} options={[
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />} { label: "Full events", value: "off" },
content={ { label: "JSONPath", value: "jsonpath" },
<HStack space={2} className="items-center"> ]}
<EventLabels event={event} index={index} isActive={isActive} /> onChange={(value) => summarySettings.setEnabled(value === "jsonpath")}
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span> />
</HStack> </div>
} {summarySettings.enabled && (
/> <>
)} <div className="min-w-40 flex-1">
renderDetail={({ event, index, onClose }) => ( <Input
<EventDetail label="Result JSON path"
event={event} hideLabel
index={index} size="xs"
showLarge={showLarge} autocomplete={sseSummaryResultKeyPathAutocomplete}
showingLarge={showingLarge} defaultValue={summarySettings.resultKeyPathInputValue}
setShowLarge={setShowLarge} forceUpdateKey={`${response.requestId}:${summarySettings.inferredResultKeyPath ?? ""}`}
setShowingLarge={setShowingLarge} placeholder="$.choices[0].delta.content"
onClose={onClose} 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({ function EventDetail({
applyJsonPath,
event, event,
index, index,
resultKeyPath,
showLarge, showLarge,
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose, onClose,
}: { }: {
applyJsonPath: boolean;
event: ServerSentEvent; event: ServerSentEvent;
index: number; index: number;
resultKeyPath: string | null;
showLarge: boolean; showLarge: boolean;
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => 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">(() => { const language = useMemo<"text" | "json">(() => {
if (!event?.data) return "text"; if (!detailText) return "text";
return isJSON(event?.data) ? "json" : "text"; return isJSON(detailText) ? "json" : "text";
}, [event?.data]); }, [detailText]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -95,7 +364,7 @@ function EventDetail({
prefix={<EventLabels event={event} index={index} />} prefix={<EventLabels event={event} index={index} />}
onClose={onClose} onClose={onClose}
/> />
{!showLarge && event.data.length > 1000 * 1000 ? ( {!showLarge && detailText.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden
<div> <div>
@@ -117,7 +386,7 @@ function EventDetail({
</div> </div>
</VStack> </VStack>
) : ( ) : (
<FormattedEditor language={language} text={event.data} /> <FormattedEditor language={language} text={detailText} />
)} )}
</div> </div>
); );
@@ -142,14 +411,17 @@ function EventLabels({
}) { }) {
return ( return (
<HStack space={1.5} alignItems="center" className={className}> <HStack space={1.5} alignItems="center" className={className}>
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}> <EventLabel isActive={isActive}>{event.id ?? index}</EventLabel>
{event.id ?? index} {event.eventType && <EventLabel isActive={isActive}>{event.eventType}</EventLabel>}
</InlineCode>
{event.eventType && (
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
{event.eventType}
</InlineCode>
)}
</HStack> </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) { export function useResponseBodyEventSource(response: HttpResponse) {
return useQuery<ServerSentEvent[]>({ return useQuery<ServerSentEvent[]>({
placeholderData: (prev) => prev, // Keep previous data on refetch 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), 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 { readFile } from "@tauri-apps/plugin-fs";
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import type { FilterResponse } from "@yaakapp-internal/plugins"; 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"; import { invokeCmd } from "./tauri";
export async function getResponseBodyText({ export async function getResponseBodyText({
@@ -27,9 +28,36 @@ export async function getResponseBodyEventSource(
response: HttpResponse, response: HttpResponse,
): Promise<ServerSentEvent[]> { ): Promise<ServerSentEvent[]> {
if (!response.bodyPath) return []; if (!response.bodyPath) return [];
return invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", { try {
filePath: response.bodyPath, 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( export async function getResponseBodyBytes(
+1
View File
@@ -1 +1,2 @@
export * from "./bindings/sse"; export * from "./bindings/sse";
export * from "./summary";
+7 -1
View File
@@ -2,5 +2,11 @@
"name": "@yaakapp-internal/sse", "name": "@yaakapp-internal/sse",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "index.ts" "dependencies": {
"jsonpath-plus": "^10.3.0"
},
"main": "index.ts",
"scripts": {
"test": "vitest run"
}
} }
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { computeSseSummary, extractSseValueAtPath } from "./summary";
describe("extractSseValueAtPath", () => {
it("supports simple paths", () => {
expect(
extractSseValueAtPath(
JSON.stringify({ choices: [{ delta: { content: "hello" } }] }),
"$.choices[0].delta.content",
),
).toBe("hello");
});
it("supports full JSONPath expressions", () => {
expect(
extractSseValueAtPath(
JSON.stringify({
choices: [
{ delta: { role: "assistant" } },
{ delta: { content: "hello" } },
{ delta: { content: " world" } },
],
}),
"$.choices[*].delta.content",
),
).toBe("hello world");
});
it("returns null when a JSONPath expression has no matches", () => {
expect(extractSseValueAtPath(JSON.stringify({ delta: {} }), "$.delta.text")).toBeNull();
});
});
describe("computeSseSummary", () => {
it("concatenates JSONPath matches across SSE messages", () => {
expect(
computeSseSummary(
[
`data: ${JSON.stringify({ choices: [{ delta: { content: "hello" } }] })}`,
"",
`data: ${JSON.stringify({ choices: [{ delta: { content: " world" } }] })}`,
"",
].join("\n"),
"$.choices[*].delta.content",
),
).toEqual({
fragmentCount: 2,
summary: "hello world",
});
});
});
+131
View File
@@ -0,0 +1,131 @@
import { JSONPath } from "jsonpath-plus";
export interface SseSummary {
fragmentCount: number;
summary: string;
}
type JSONPathJson = null | boolean | number | string | object | unknown[];
const STANDARD_SSE_FIELD = /^(event|id|retry):/i;
export function candidateJsonPayloadsFromSseText(text: string): string[] {
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const blocks = normalized.split(/\n{2,}/);
const candidates: string[] = [];
for (const block of blocks) {
const lines = block.split("\n");
const dataLines = lines
.map((line) => {
const match = /^data:(?: ?)(.*)$/.exec(line);
return match?.[1];
})
.filter((line): line is string => line != null);
if (dataLines.length > 0) {
const payload = dataLines.join("\n").trim();
if (payload) {
candidates.push(payload);
}
continue;
}
const trimmedBlock = block.trim();
if (!trimmedBlock) {
continue;
}
if (isParsableJson(trimmedBlock)) {
candidates.push(trimmedBlock);
continue;
}
for (const line of lines) {
const trimmedLine = line.trim();
if (
!trimmedLine ||
trimmedLine.startsWith(":") ||
STANDARD_SSE_FIELD.test(trimmedLine) ||
!isParsableJson(trimmedLine)
) {
continue;
}
candidates.push(trimmedLine);
}
}
return candidates;
}
export function computeSseSummary(text: string, keyPath: string): SseSummary {
const fragments: string[] = [];
for (const payload of candidateJsonPayloadsFromSseText(text)) {
const fragment = extractSseValueAtPath(payload, keyPath);
if (fragment != null) {
fragments.push(fragment);
}
}
return {
fragmentCount: fragments.length,
summary: fragments.join(""),
};
}
export function extractSseValueAtPath(payload: string, keyPath: string): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
return null;
}
const path = keyPath.trim();
if (!path) {
return null;
}
let result: unknown;
try {
result = JSONPath({ path, json: parsed as JSONPathJson });
} catch {
return null;
}
if (Array.isArray(result)) {
const fragments = result
.map((item) => stringifySummaryValue(item))
.filter((item): item is string => item != null);
return fragments.length > 0 ? fragments.join("") : null;
}
return stringifySummaryValue(result);
}
function stringifySummaryValue(value: unknown): string | null {
if (value == null) {
return null;
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return null;
}
}
function isParsableJson(value: string): boolean {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
+4 -1
View File
@@ -356,7 +356,10 @@
}, },
"crates/yaak-sse": { "crates/yaak-sse": {
"name": "@yaakapp-internal/sse", "name": "@yaakapp-internal/sse",
"version": "1.0.0" "version": "1.0.0",
"dependencies": {
"jsonpath-plus": "^10.3.0"
}
}, },
"crates/yaak-sync": { "crates/yaak-sync": {
"name": "@yaakapp-internal/sync", "name": "@yaakapp-internal/sync",
+38 -12
View File
@@ -27,7 +27,7 @@ interface Props {
resizeHandleClassName?: string; resizeHandleClassName?: string;
} }
const baseProperties = { minWidth: 0 }; const baseProperties = { minHeight: 0, minWidth: 0 };
const areaL = { ...baseProperties, gridArea: "left" }; const areaL = { ...baseProperties, gridArea: "left" };
const areaR = { ...baseProperties, gridArea: "right" }; const areaR = { ...baseProperties, gridArea: "right" };
const areaD = { ...baseProperties, gridArea: "drag" }; const areaD = { ...baseProperties, gridArea: "drag" };
@@ -60,23 +60,25 @@ export function SplitLayout({
const size = useContainerSize(containerRef); const size = useContainerSize(containerRef);
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH; const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize); const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize);
const renderedWidth = clampSplitRatio(width, minWidthPx, size.width);
const renderedHeight = secondSlot ? clampSplitRatio(height, minHeightPx, size.height) : 0;
const styles = useMemo<CSSProperties>(() => { const styles = useMemo<CSSProperties>(() => {
return { return {
...style, ...style,
gridTemplate: vertical gridTemplate: vertical
? ` ? `
' ${areaL.gridArea}' minmax(0,${1 - height}fr) ' ${areaL.gridArea}' minmax(0,${1 - renderedHeight}fr)
' ${areaD.gridArea}' 0 ' ${areaD.gridArea}' 0
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr) ' ${areaR.gridArea}' minmax(0,${renderedHeight}fr)
/ 1fr / 1fr
` `
: ` : `
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr) ' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr / ${1 - renderedWidth}fr 0 ${renderedWidth}fr
`, `,
}; };
}, [style, vertical, height, minHeightPx, width]); }, [style, vertical, renderedHeight, renderedWidth]);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
if (vertical) setHeight(defaultRatio); if (vertical) setHeight(defaultRatio);
@@ -96,22 +98,36 @@ export function SplitLayout({
const containerHeight = const containerHeight =
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom); $c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
if ((vertical && containerHeight <= 0) || (!vertical && containerWidth <= 0)) {
return;
}
const mouseStartX = e.xStart; const mouseStartX = e.xStart;
const mouseStartY = e.yStart; const mouseStartY = e.yStart;
const startWidth = containerWidth * width; const startWidth = containerWidth * renderedWidth;
const startHeight = containerHeight * height; const startHeight = containerHeight * renderedHeight;
if (vertical) { if (vertical) {
const maxHeightPx = containerHeight - minHeightPx; const minHeight = Math.min(minHeightPx, containerHeight);
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx); const maxHeightPx = Math.max(minHeight, containerHeight - minHeightPx);
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeight, maxHeightPx);
setHeight(newHeightPx / containerHeight); setHeight(newHeightPx / containerHeight);
} else { } else {
const maxWidthPx = containerWidth - minWidthPx; const minWidth = Math.min(minWidthPx, containerWidth);
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx); const maxWidthPx = Math.max(minWidth, containerWidth - minWidthPx);
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidth, maxWidthPx);
setWidth(newWidthPx / containerWidth); setWidth(newWidthPx / containerWidth);
} }
}, },
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth], [
renderedWidth,
renderedHeight,
vertical,
minHeightPx,
setHeight,
minWidthPx,
setWidth,
],
); );
return ( return (
@@ -140,3 +156,13 @@ export function SplitLayout({
</div> </div>
); );
} }
function clampSplitRatio(ratio: number, minPx: number, containerPx: number): number {
if (containerPx <= 0 || minPx <= 0) {
return ratio;
}
const minRatio = Math.min(1, minPx / containerPx);
const maxRatio = minRatio >= 0.5 ? minRatio : 1 - minRatio;
return clamp(ratio, minRatio, maxRatio);
}