mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-02 02:51:40 +02:00
Add SSE response summary helpers (#466)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 +1,2 @@
|
|||||||
export * from "./bindings/sse";
|
export * from "./bindings/sse";
|
||||||
|
export * from "./summary";
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4
-1
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user