🖥️ 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:
Danny Avila 2026-05-05 12:24:04 -04:00
parent 30e5f15dfb
commit ba8a845f34
11 changed files with 626 additions and 1 deletions

View file

@ -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>
);
});

View file

@ -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),
},
);
};

View file

@ -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);
});
});

View file

@ -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';

View 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,
};
}

View 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',
});
});
});

View file

@ -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],

View file

@ -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",

View file

@ -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}`;

View file

@ -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));
};

View file

@ -34,6 +34,7 @@ export enum QueryKeys {
assistantDocs = 'assistantDocs',
agentDocs = 'agentDocs',
fileDownload = 'fileDownload',
filePreview = 'filePreview',
voices = 'voices',
customConfigSpeech = 'customConfigSpeech',
prompts = 'prompts',