mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
🖥️ feat: frontend two-phase preview consumer (polling + UI states)
Wires the React side to the new lifecycle so the user sees what's happening with their file while phase-2 extraction runs in the background and after the response stream closes. - `useAttachmentHandler` upserts by `file_id` (was append-only) so the phase-2 SSE update event merges over the pending placeholder in place. Lightweight attachments without a `file_id` (web_search / file_search citations) keep the legacy append path. - `useFilePreview(file_id)` React Query hook with `refetchInterval: (data) => data?.status === 'pending' ? 2500 : false` so polling auto-stops on the first terminal response without the caller having to flip `enabled`. - `useAttachmentPreviewSync(attachment)` bridges polled data into `messageAttachmentsMap`. Polling enabled iff `status === 'pending' && isAnySubmitting` — per the design ask: active polling while the LLM is still generating, then quiet. Process-restart and post-stream cases are covered by polling on the next interaction. - `Attachment.tsx` renders a small `PreviewStatusIndicator` (spinner + "Preparing preview…" for pending, alert icon + "Preview unavailable" for failed) inside `FileAttachment`. Download button stays fully functional in both states. Two new English locale keys. - Data-provider scaffolding: `TFilePreview` type, `endpoints.filePreview`, `dataService.getFilePreview`, `QueryKeys.filePreview`.
This commit is contained in:
parent
30e5f15dfb
commit
ba8a845f34
11 changed files with 626 additions and 1 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
||||
import type { ToolArtifactType } from '~/utils/artifacts';
|
||||
|
|
@ -18,11 +19,50 @@ import Image from '~/components/Chat/Messages/Content/Image';
|
|||
import ToolMermaidArtifact from './ToolMermaidArtifact';
|
||||
import ToolArtifactCard from './ToolArtifactCard';
|
||||
import { useAttachmentLink } from './LogLink';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useLocalize, useAttachmentPreviewSync } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const COLLAPSED_MAX_HEIGHT = 320;
|
||||
|
||||
/**
|
||||
* Small inline indicator rendered next to a code-execution file chip
|
||||
* while its inline preview is in flight or has failed. Three states:
|
||||
*
|
||||
* - `'pending'`: tiny spinner + "preparing preview…". Driven by the
|
||||
* polling `useAttachmentPreviewSync` hook (gated on `isSubmitting`)
|
||||
* and by the SSE `attachment` update event when the response stream
|
||||
* is still open.
|
||||
* - `'failed'`: alert icon + "preview unavailable" with the
|
||||
* backend's machine-readable `previewError` as a tooltip
|
||||
* (`'timeout'`, `'parser-error'`, `'orphaned'`, …). The download
|
||||
* chip itself remains fully functional.
|
||||
* - `'ready'` (or undefined): nothing rendered — the chip looks
|
||||
* exactly as it did before this PR.
|
||||
*/
|
||||
const PreviewStatusIndicator = memo(
|
||||
({ status, previewError }: { status: 'pending' | 'failed'; previewError?: string }) => {
|
||||
const localize = useLocalize();
|
||||
if (status === 'pending') {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
<span>{localize('com_ui_preview_preparing')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="mt-1 flex items-center gap-1.5 text-xs text-text-secondary"
|
||||
title={previewError ?? localize('com_ui_preview_failed')}
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{localize('com_ui_preview_failed')}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
PreviewStatusIndicator.displayName = 'PreviewStatusIndicator';
|
||||
|
||||
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const file = attachment as TFile & TAttachmentMetadata;
|
||||
|
|
@ -34,6 +74,14 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
|
|||
source: file.source,
|
||||
});
|
||||
const extension = attachment.filename?.split('.').pop();
|
||||
/* Bridge the two-phase preview lifecycle: poll the backend for the
|
||||
* resolved record while pending+isSubmitting, and surface a small
|
||||
* UI indicator. The hook is a no-op for terminal states (legacy
|
||||
* records, ready, failed already-known) so calling it
|
||||
* unconditionally is cheap. */
|
||||
const { status: previewStatus, previewError } = useAttachmentPreviewSync(
|
||||
attachment as TAttachment,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
|
|
@ -64,6 +112,9 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
|
|||
containerClassName="max-w-fit"
|
||||
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
|
||||
/>
|
||||
{(previewStatus === 'pending' || previewStatus === 'failed') && (
|
||||
<PreviewStatusIndicator status={previewStatus} previewError={previewError} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -105,3 +105,41 @@ export const useCodeOutputDownload = (url = ''): QueryObserverResult<string> =>
|
|||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Poll the lifecycle of an inline file preview while phase-2 HTML
|
||||
* extraction runs in the background.
|
||||
*
|
||||
* Caller wires `enabled` to `(attachment.status === 'pending') && isSubmitting`
|
||||
* so the query is dormant for terminal-status records and stops the
|
||||
* moment the assistant finishes generating. Once enabled, React Query's
|
||||
* `refetchInterval` runs at 2.5s while the server keeps returning
|
||||
* `'pending'`, then auto-stops on the first `'ready'`/`'failed'`
|
||||
* response (returning `false` from the function form disables the
|
||||
* interval). Idle by default.
|
||||
*
|
||||
* Cache key: `[QueryKeys.filePreview, file_id]`. Sibling components
|
||||
* watching the same `file_id` get a single shared poller.
|
||||
*/
|
||||
export const useFilePreview = (
|
||||
file_id: string | undefined,
|
||||
config?: UseQueryOptions<t.TFilePreview, unknown, t.TFilePreview>,
|
||||
): QueryObserverResult<t.TFilePreview, unknown> => {
|
||||
return useQuery<t.TFilePreview, unknown, t.TFilePreview>(
|
||||
[QueryKeys.filePreview, file_id],
|
||||
() => dataService.getFilePreview(file_id ?? ''),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
retry: false,
|
||||
/* Function form: poll only while the server says 'pending'.
|
||||
* Returning `false` disables the interval, so a single 'ready'
|
||||
* or 'failed' response naturally terminates polling without the
|
||||
* caller having to also flip `enabled`. */
|
||||
refetchInterval: (data) => (data?.status === 'pending' ? 2500 : false),
|
||||
...config,
|
||||
enabled: !!file_id && (config?.enabled ?? true),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* Coverage for `useAttachmentPreviewSync` — the bridge between the
|
||||
* two-phase code-execution preview lifecycle and the attachment cache.
|
||||
*
|
||||
* Behavior under test:
|
||||
* 1. Polling enables only when `attachment.status === 'pending'` AND
|
||||
* some conversation is submitting (per the user's explicit gate).
|
||||
* 2. On a terminal poll response (ready/failed), the resolved record
|
||||
* is upserted into `messageAttachmentsMap` by `file_id`.
|
||||
* 3. The hook's returned `status` reflects the polled value once
|
||||
* it arrives, not just the prop snapshot.
|
||||
*
|
||||
* The underlying `useFilePreview` hook is mocked here — its own
|
||||
* polling cadence and refetchInterval semantics are React Query's
|
||||
* concern, not this hook's.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TAttachment, TFilePreview } from 'librechat-data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
const mockUseFilePreview = jest.fn();
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useFilePreview: (...args: unknown[]) => mockUseFilePreview(...args),
|
||||
}));
|
||||
|
||||
import useAttachmentPreviewSync from '../useAttachmentPreviewSync';
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <RecoilRoot>{children}</RecoilRoot>;
|
||||
|
||||
const messageId = 'msg-1';
|
||||
const fileId = 'fid-1';
|
||||
|
||||
function makeAttachment(overrides: Partial<TAttachment> = {}): TAttachment {
|
||||
return {
|
||||
file_id: fileId,
|
||||
filename: 'data.xlsx',
|
||||
filepath: '/uploads/data.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
messageId,
|
||||
toolCallId: 'tc-1',
|
||||
text: null,
|
||||
textFormat: null,
|
||||
status: 'pending',
|
||||
...overrides,
|
||||
} as unknown as TAttachment;
|
||||
}
|
||||
|
||||
/** Read messageAttachmentsMap and bridge it out via a mutable ref. */
|
||||
function setup({
|
||||
attachment,
|
||||
isSubmitting,
|
||||
preview,
|
||||
isFetching = false,
|
||||
}: {
|
||||
attachment: TAttachment;
|
||||
isSubmitting: boolean;
|
||||
preview?: TFilePreview;
|
||||
isFetching?: boolean;
|
||||
}) {
|
||||
mockUseFilePreview.mockReset();
|
||||
mockUseFilePreview.mockReturnValue({ data: preview, isFetching });
|
||||
const ref: { current: Record<string, TAttachment[] | undefined> } = { current: {} };
|
||||
let lastEnabled: boolean | undefined;
|
||||
/* Spy on the second arg of useFilePreview to assert the gate. */
|
||||
mockUseFilePreview.mockImplementation((_id: unknown, opts: { enabled?: boolean }) => {
|
||||
lastEnabled = opts?.enabled;
|
||||
return { data: preview, isFetching };
|
||||
});
|
||||
|
||||
const Bridge = () => {
|
||||
/* Seed the messageAttachmentsMap with the test attachment so the
|
||||
* hook's upsert path has something to find. Also expose setters
|
||||
* for tests that simulate `isAnySubmitting` toggling — the
|
||||
* selector reads `conversationKeysAtom` × `isSubmittingFamily(key)`
|
||||
* so we have to populate both for the selector to fire. */
|
||||
const setMap = useSetRecoilState(store.messageAttachmentsMap);
|
||||
if (Object.keys(ref.current).length === 0) {
|
||||
setMap({ [messageId]: [attachment] });
|
||||
}
|
||||
const setKeys = useSetRecoilState(store.conversationKeysAtom);
|
||||
setKeys([0]);
|
||||
const setSubmitting = useSetRecoilState(store.isSubmittingFamily(0));
|
||||
setSubmitting(isSubmitting);
|
||||
const map = useRecoilValue(store.messageAttachmentsMap);
|
||||
ref.current = map;
|
||||
return null;
|
||||
};
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
return useAttachmentPreviewSync(attachment);
|
||||
},
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) =>
|
||||
wrapper({
|
||||
children: (
|
||||
<>
|
||||
<Bridge />
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
result,
|
||||
get map() {
|
||||
return ref.current;
|
||||
},
|
||||
get enabled() {
|
||||
return lastEnabled;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useAttachmentPreviewSync', () => {
|
||||
it('enables polling only when status=pending AND a conversation is submitting', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'pending' }),
|
||||
isSubmitting: true,
|
||||
});
|
||||
expect(ctx.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT enable polling when status is already ready', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'ready' }),
|
||||
isSubmitting: true,
|
||||
});
|
||||
expect(ctx.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT enable polling when status is already failed', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'failed', previewError: 'timeout' }),
|
||||
isSubmitting: true,
|
||||
});
|
||||
expect(ctx.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT enable polling when no conversation is submitting (LLM finished)', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'pending' }),
|
||||
isSubmitting: false,
|
||||
});
|
||||
/* User's explicit gate: once the LLM is done, polling stops. The
|
||||
* frontend can refetch on demand from the next interaction. */
|
||||
expect(ctx.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT enable polling when the attachment has no file_id', () => {
|
||||
const noId = { ...makeAttachment({ status: 'pending' }) } as Partial<TAttachment>;
|
||||
delete (noId as { file_id?: string }).file_id;
|
||||
const ctx = setup({
|
||||
attachment: noId as TAttachment,
|
||||
isSubmitting: true,
|
||||
});
|
||||
expect(ctx.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns ready when no preview and no status (legacy back-compat)', () => {
|
||||
const legacy = makeAttachment();
|
||||
delete (legacy as Partial<TAttachment & { status?: string }>).status;
|
||||
const ctx = setup({ attachment: legacy as TAttachment, isSubmitting: false });
|
||||
expect(ctx.result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('upserts the resolved preview into messageAttachmentsMap when phase-2 reports ready', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'pending' }),
|
||||
isSubmitting: true,
|
||||
preview: {
|
||||
file_id: fileId,
|
||||
status: 'ready',
|
||||
text: '<table>final</table>',
|
||||
textFormat: 'html',
|
||||
},
|
||||
});
|
||||
const updated = ctx.map[messageId]?.[0] as TAttachment & { text?: string };
|
||||
expect(updated.status).toBe('ready');
|
||||
expect(updated.text).toBe('<table>final</table>');
|
||||
expect(ctx.result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('upserts the resolved preview when phase-2 reports failed (with previewError)', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'pending' }),
|
||||
isSubmitting: true,
|
||||
preview: {
|
||||
file_id: fileId,
|
||||
status: 'failed',
|
||||
previewError: 'parser-error',
|
||||
},
|
||||
});
|
||||
const updated = ctx.map[messageId]?.[0] as TAttachment & { previewError?: string };
|
||||
expect(updated.status).toBe('failed');
|
||||
expect(updated.previewError).toBe('parser-error');
|
||||
expect(ctx.result.current.status).toBe('failed');
|
||||
expect(ctx.result.current.previewError).toBe('parser-error');
|
||||
});
|
||||
|
||||
it('does NOT upsert while the polled status is still pending', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'pending' }),
|
||||
isSubmitting: true,
|
||||
preview: { file_id: fileId, status: 'pending' },
|
||||
});
|
||||
/* Map should be unchanged from the initial seed — no patch. */
|
||||
const list = ctx.map[messageId] ?? [];
|
||||
expect(list).toHaveLength(1);
|
||||
expect((list[0] as TAttachment & { status?: string }).status).toBe('pending');
|
||||
});
|
||||
|
||||
it('reports isPolling true when the query is fetching and the gate is open', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'pending' }),
|
||||
isSubmitting: true,
|
||||
isFetching: true,
|
||||
});
|
||||
expect(ctx.result.current.isPolling).toBe(true);
|
||||
});
|
||||
|
||||
it('reports isPolling false when the query is not fetching', () => {
|
||||
const ctx = setup({
|
||||
attachment: makeAttachment({ status: 'pending' }),
|
||||
isSubmitting: true,
|
||||
isFetching: false,
|
||||
});
|
||||
expect(ctx.result.current.isPolling).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { default as useAttachmentPreviewSync } from './useAttachmentPreviewSync';
|
||||
export { default as useDeleteFilesFromTable } from './useDeleteFilesFromTable';
|
||||
export { default as useSetFilesToDelete } from './useSetFilesToDelete';
|
||||
export { default as useFileHandling, useFileHandlingNoChatContext } from './useFileHandling';
|
||||
|
|
|
|||
108
client/src/hooks/Files/useAttachmentPreviewSync.ts
Normal file
108
client/src/hooks/Files/useAttachmentPreviewSync.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider';
|
||||
import { useFilePreview } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
interface UseAttachmentPreviewSyncResult {
|
||||
/**
|
||||
* Effective lifecycle status: `'pending'` while phase-2 HTML
|
||||
* extraction is in flight, `'ready'` once it completes successfully
|
||||
* (or for legacy / non-office files that never had a status), and
|
||||
* `'failed'` when extraction errored or hit the 60s ceiling. Drives
|
||||
* UI state (spinner, badge, etc.) without callers needing to read
|
||||
* the attachment shape directly.
|
||||
*/
|
||||
status: 'pending' | 'ready' | 'failed';
|
||||
/** Short machine-readable failure reason from the backend. */
|
||||
previewError?: string;
|
||||
/** True while React Query is actively polling the preview endpoint. */
|
||||
isPolling: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge the two-phase code-execution preview lifecycle to the
|
||||
* attachment cache.
|
||||
*
|
||||
* Phase-1 (handled in callbacks.js + processCodeOutput) emits the
|
||||
* attachment record at `status: 'pending'` immediately so the agent's
|
||||
* response stops blocking on extraction. Phase-2 runs in the
|
||||
* background; if the SSE stream is still open when it lands, an
|
||||
* `attachment` update event arrives and the SSE handler upserts by
|
||||
* `file_id`. If the stream has already closed (the model finished
|
||||
* generating before phase-2 resolved), this hook covers the gap by
|
||||
* polling `GET /api/files/:file_id/preview` and writing the resolved
|
||||
* record back into `messageAttachmentsMap` — which triggers
|
||||
* re-classification through `artifactTypeForAttachment`, so the file
|
||||
* chip transitions from a plain download to the rich preview card
|
||||
* (or to a download-with-error state) without remounting.
|
||||
*
|
||||
* Polling is gated on:
|
||||
* - `attachment.file_id` present (no id → nothing to poll for)
|
||||
* - Effective status is `'pending'` (terminal states need no work)
|
||||
* - Some conversation in the app is submitting (per the user's
|
||||
* explicit gate: "only query when the preview is still live and
|
||||
* `isSubmitting` is still true"). When the LLM finishes, polling
|
||||
* stops; the user can refresh on demand from the next interaction.
|
||||
*/
|
||||
export default function useAttachmentPreviewSync(
|
||||
attachment: TAttachment | undefined,
|
||||
): UseAttachmentPreviewSyncResult {
|
||||
const setAttachmentsMap = useSetRecoilState(store.messageAttachmentsMap);
|
||||
const isAnySubmitting = useRecoilValue(store.anySubmittingSelector);
|
||||
|
||||
const file = (attachment ?? undefined) as Partial<TFile> | undefined;
|
||||
const fileId = file?.file_id;
|
||||
const baseStatus: 'pending' | 'ready' | 'failed' = file?.status ?? 'ready';
|
||||
const messageId = (attachment as Partial<TAttachment> | undefined)?.messageId;
|
||||
|
||||
const enabled = !!fileId && baseStatus === 'pending' && isAnySubmitting;
|
||||
|
||||
const previewQuery = useFilePreview(fileId, { enabled });
|
||||
|
||||
/* Effective status: prefer the polled record once it arrives, since
|
||||
* the SSE handler may have already moved the cache forward and the
|
||||
* `attachment` prop will catch up on the next render anyway. */
|
||||
const polled = previewQuery.data as TFilePreview | undefined;
|
||||
const effectiveStatus: 'pending' | 'ready' | 'failed' = polled?.status ?? baseStatus;
|
||||
const previewError = polled?.previewError ?? file?.previewError;
|
||||
|
||||
/* On a terminal poll response (ready or failed), upsert into the
|
||||
* shared attachments map. Mirrors the SSE handler's by-file_id
|
||||
* upsert (`useAttachmentHandler`) — the attachment object is
|
||||
* patched in place so siblings sharing the same atom re-render
|
||||
* with the resolved data and `artifactTypeForAttachment` re-runs
|
||||
* its empty-text gate, transitioning the file chip into a panel
|
||||
* artifact card. */
|
||||
useEffect(() => {
|
||||
if (!polled || polled.status === 'pending' || !messageId || !fileId) {
|
||||
return;
|
||||
}
|
||||
setAttachmentsMap((prevMap) => {
|
||||
const messageAttachments =
|
||||
(prevMap as Record<string, TAttachment[] | undefined>)[messageId] || [];
|
||||
const existingIndex = messageAttachments.findIndex(
|
||||
(a) => (a as Partial<TFile>).file_id === fileId,
|
||||
);
|
||||
if (existingIndex < 0) {
|
||||
return prevMap;
|
||||
}
|
||||
const existing = messageAttachments[existingIndex] as Partial<TFile> & TAttachment;
|
||||
const merged = [...messageAttachments];
|
||||
merged[existingIndex] = {
|
||||
...existing,
|
||||
status: polled.status,
|
||||
text: polled.text ?? existing.text ?? null,
|
||||
textFormat: polled.textFormat ?? existing.textFormat ?? null,
|
||||
previewError: polled.previewError,
|
||||
} as TAttachment;
|
||||
return { ...prevMap, [messageId]: merged };
|
||||
});
|
||||
}, [polled, fileId, messageId, setAttachmentsMap]);
|
||||
|
||||
return {
|
||||
status: effectiveStatus,
|
||||
previewError,
|
||||
isPolling: enabled && previewQuery.isFetching,
|
||||
};
|
||||
}
|
||||
148
client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx
Normal file
148
client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Coverage for the upsert-by-file_id behavior in `useAttachmentHandler`.
|
||||
*
|
||||
* Two-phase code-execution flow: phase-1 emits an attachment with
|
||||
* `status: 'pending'`; phase-2 emits the same `file_id` again with
|
||||
* `status: 'ready'` (and resolved `text`/`textFormat`) or
|
||||
* `'failed'` (with `previewError`). The handler MUST merge over the
|
||||
* pending placeholder in place — appending would render the artifact
|
||||
* twice in the UI (once stuck pending, once resolved).
|
||||
*
|
||||
* Lightweight attachments without a `file_id` (web_search citations,
|
||||
* file_search results) keep the legacy append-only behavior so two
|
||||
* unrelated citations both show up.
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TAttachment, EventSubmission } from 'librechat-data-provider';
|
||||
import useAttachmentHandler from '../useAttachmentHandler';
|
||||
import store from '~/store';
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <RecoilRoot>{children}</RecoilRoot>;
|
||||
|
||||
const submission = {} as EventSubmission;
|
||||
const messageId = 'msg-1';
|
||||
|
||||
function makeAttachment(overrides: Partial<TAttachment>): TAttachment {
|
||||
return {
|
||||
file_id: 'fid-1',
|
||||
filename: 'data.xlsx',
|
||||
filepath: '/uploads/data.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
messageId,
|
||||
toolCallId: 'tc-1',
|
||||
text: null,
|
||||
textFormat: null,
|
||||
status: 'pending',
|
||||
...overrides,
|
||||
} as unknown as TAttachment;
|
||||
}
|
||||
|
||||
/* Co-mount the handler and a reader of the messageAttachmentsMap atom in
|
||||
* the same RecoilRoot so each act() shows the post-write state. The
|
||||
* `attachmentsMap` ref is mutated by the reader on every render, so
|
||||
* callers read it after their `act()` to assert. */
|
||||
function setup() {
|
||||
const queryClient = new QueryClient();
|
||||
const ref: { current: Record<string, TAttachment[] | undefined> } = { current: {} };
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const handler = useAttachmentHandler(queryClient);
|
||||
const map = useRecoilValue(store.messageAttachmentsMap);
|
||||
ref.current = map;
|
||||
return handler;
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
return {
|
||||
handle: (data: TAttachment) => act(() => result.current({ data, submission })),
|
||||
get list(): TAttachment[] {
|
||||
return ref.current[messageId] ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useAttachmentHandler upsert-by-file_id', () => {
|
||||
it('appends a new attachment when no record with the same file_id exists', () => {
|
||||
const ctx = setup();
|
||||
ctx.handle(makeAttachment({ status: 'pending' }));
|
||||
expect(ctx.list).toHaveLength(1);
|
||||
expect(ctx.list[0]).toMatchObject({ file_id: 'fid-1', status: 'pending' });
|
||||
});
|
||||
|
||||
it('upserts in place when a second event arrives for the same file_id (phase-1 → phase-2 ready)', () => {
|
||||
const ctx = setup();
|
||||
ctx.handle(makeAttachment({ status: 'pending' }));
|
||||
ctx.handle(makeAttachment({ status: 'ready', text: '<table></table>', textFormat: 'html' }));
|
||||
/* Critical: still ONE attachment, not two. The phase-2 event
|
||||
* patches the pending record in place. */
|
||||
expect(ctx.list).toHaveLength(1);
|
||||
expect(ctx.list[0]).toMatchObject({
|
||||
file_id: 'fid-1',
|
||||
status: 'ready',
|
||||
text: '<table></table>',
|
||||
textFormat: 'html',
|
||||
});
|
||||
});
|
||||
|
||||
it('upserts a failed phase-2 over the pending placeholder', () => {
|
||||
const ctx = setup();
|
||||
ctx.handle(makeAttachment({ status: 'pending' }));
|
||||
ctx.handle(makeAttachment({ status: 'failed', previewError: 'timeout' }));
|
||||
expect(ctx.list).toHaveLength(1);
|
||||
expect(ctx.list[0]).toMatchObject({ status: 'failed', previewError: 'timeout' });
|
||||
});
|
||||
|
||||
it('keeps multiple distinct file_ids as separate entries', () => {
|
||||
const ctx = setup();
|
||||
ctx.handle(makeAttachment({ file_id: 'fid-A' }));
|
||||
ctx.handle(makeAttachment({ file_id: 'fid-B' }));
|
||||
expect(ctx.list).toHaveLength(2);
|
||||
expect(ctx.list.map((a) => (a as { file_id: string }).file_id).sort()).toEqual([
|
||||
'fid-A',
|
||||
'fid-B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends (does NOT merge) attachments with no file_id', () => {
|
||||
/* Lightweight attachments like file_search citations and web_search
|
||||
* results don't carry file_id. The handler must keep its legacy
|
||||
* append behavior for them — merging would lose distinct citations
|
||||
* and is unnecessary because they're never the target of a phase-2
|
||||
* update. */
|
||||
const ctx = setup();
|
||||
const noFileId = {
|
||||
messageId,
|
||||
toolCallId: 'tc-1',
|
||||
type: Tools.web_search,
|
||||
} as unknown as TAttachment;
|
||||
ctx.handle(noFileId);
|
||||
ctx.handle(noFileId);
|
||||
expect(ctx.list).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('preserves fields from the first event when the second omits them', () => {
|
||||
/* Phase-2 update only carries the deltas (text, status, textFormat).
|
||||
* Fields set in phase-1 (filename, type, etc.) must survive the
|
||||
* merge — the second event uses spread-over-existing semantics. */
|
||||
const ctx = setup();
|
||||
ctx.handle(makeAttachment({ status: 'pending', filename: 'phase1-name.xlsx' }));
|
||||
ctx.handle({
|
||||
file_id: 'fid-1',
|
||||
messageId,
|
||||
status: 'ready',
|
||||
text: 'final',
|
||||
textFormat: 'html',
|
||||
} as unknown as TAttachment);
|
||||
expect(ctx.list).toHaveLength(1);
|
||||
expect(ctx.list[0]).toMatchObject({
|
||||
filename: 'phase1-name.xlsx',
|
||||
status: 'ready',
|
||||
text: 'final',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -52,6 +52,26 @@ export default function useAttachmentHandler(queryClient?: QueryClient) {
|
|||
setAttachmentsMap((prevMap) => {
|
||||
const messageAttachments =
|
||||
(prevMap as Record<string, TAttachment[] | undefined>)[messageId] || [];
|
||||
/* Upsert by `file_id` rather than always appending. The two-phase
|
||||
* code-execution flow emits the same attachment twice: once at
|
||||
* phase-1 with `status: 'pending'` and `text: null`, then again
|
||||
* at phase-2 with `status: 'ready'` (and text/textFormat) or
|
||||
* `'failed'` (with previewError). The second event must merge
|
||||
* over the first in place — appending would render the artifact
|
||||
* card twice, once stuck pending and once resolved. Attachments
|
||||
* without a `file_id` (lightweight types like web_search /
|
||||
* file_search citations) keep the legacy append behavior. */
|
||||
const fileId = (data as Partial<TFile>).file_id;
|
||||
if (fileId) {
|
||||
const existingIndex = messageAttachments.findIndex(
|
||||
(a) => (a as Partial<TFile>).file_id === fileId,
|
||||
);
|
||||
if (existingIndex > -1) {
|
||||
const merged = [...messageAttachments];
|
||||
merged[existingIndex] = { ...messageAttachments[existingIndex], ...data };
|
||||
return { ...prevMap, [messageId]: merged };
|
||||
}
|
||||
}
|
||||
return {
|
||||
...prevMap,
|
||||
[messageId]: [...messageAttachments, data],
|
||||
|
|
|
|||
|
|
@ -1304,6 +1304,8 @@
|
|||
"com_ui_prev": "Prev",
|
||||
"com_ui_prev_result": "Previous result",
|
||||
"com_ui_preview": "Preview",
|
||||
"com_ui_preview_failed": "Preview unavailable",
|
||||
"com_ui_preview_preparing": "Preparing preview…",
|
||||
"com_ui_preview_unavailable": "Preview not available for this file type",
|
||||
"com_ui_privacy_policy": "Privacy policy",
|
||||
"com_ui_privacy_policy_url": "Privacy Policy URL",
|
||||
|
|
|
|||
|
|
@ -284,6 +284,11 @@ export const fileUpload = () => `${BASE_URL}/api/files`;
|
|||
export const fileDelete = () => `${BASE_URL}/api/files`;
|
||||
export const fileDownload = (userId: string, fileId: string) =>
|
||||
`${BASE_URL}/api/files/download/${userId}/${fileId}`;
|
||||
/* Two-phase code-execution preview lifecycle endpoint. Returns
|
||||
* `{ status, text?, textFormat?, previewError? }` so the frontend can
|
||||
* poll while phase-2 HTML extraction is in flight. See PR #12952. */
|
||||
export const filePreview = (fileId: string) =>
|
||||
`${BASE_URL}/api/files/${encodeURIComponent(fileId)}/preview`;
|
||||
export const fileConfig = () => `${BASE_URL}/api/files/config`;
|
||||
export const agentFiles = (agentId: string) => `${BASE_URL}/api/files/agent/${agentId}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -402,6 +402,22 @@ export const getFiles = (): Promise<f.TFile[]> => {
|
|||
return request.get(endpoints.files());
|
||||
};
|
||||
|
||||
/**
|
||||
* Poll the lifecycle of an inline file preview. Returns the smallest
|
||||
* shape needed to drive the UI:
|
||||
* - `status` always present (defaults to `'ready'` server-side for
|
||||
* legacy records that pre-date the field).
|
||||
* - `text` and `textFormat` only when `status === 'ready'` and text
|
||||
* was extracted (preserves the HTML-or-null security contract).
|
||||
* - `previewError` only when `status === 'failed'`.
|
||||
*
|
||||
* Called from `useFilePreview` with React Query's `refetchInterval`
|
||||
* gated on `status === 'pending'` and `isSubmitting`. See PR #12952.
|
||||
*/
|
||||
export const getFilePreview = (fileId: string): Promise<f.TFilePreview> => {
|
||||
return request.get(endpoints.filePreview(fileId));
|
||||
};
|
||||
|
||||
export const getAgentFiles = (agentId: string): Promise<f.TFile[]> => {
|
||||
return request.get(endpoints.agentFiles(agentId));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export enum QueryKeys {
|
|||
assistantDocs = 'assistantDocs',
|
||||
agentDocs = 'agentDocs',
|
||||
fileDownload = 'fileDownload',
|
||||
filePreview = 'filePreview',
|
||||
voices = 'voices',
|
||||
customConfigSpeech = 'customConfigSpeech',
|
||||
prompts = 'prompts',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue