mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
📋 feat: Attach Long Pasted Text as a File (#14884)
* Attach long pasted text as a file Pasting more than 2500 characters into the composer now attaches the text as pasted-text.txt instead of filling the message box. The text still reaches the model in full: the attachment is routed to the context tool resource, which inlines it verbatim. Shorter pastes and pasted files keep their existing behavior. Add a "Paste long text as a file" toggle under Settings > Chat > Sending, on by default and persisted locally. Number successive pastes so uploads, which dedupe on name, size and type, do not reject a second paste that merely matches the first one's length. handleFiles now reports whether files were accepted, so the "Attached as text" toast is held until the attachment actually happens instead of pairing a success message with a rejection error. * fix: Respect long paste threshold * fix: Preserve long paste semantics * Fix long-paste upload failure recovery and copy * Fix concurrent paste upload recovery * Guard asynchronous paste recovery * Fix long paste handling in the composer * fix: skip delayed paste recovery in answer mode * fix paste recovery cleanup on attachment removal * fix paste recovery across drafts and reloads * fix paste recovery isolation across side-by-side panes * fix idle new-chat draft isolation and paste replacement recovery * fix pane-scoped draft cleanup and multi-paste restore offsets * fix paste recovery around run end, live uploads, and draft edits * fix paste recovery when both sides of the caret were edited * fix new-chat draft cleanup, pane-scoped abort recovery, and one-character snapshots * fix paste persistence failures and pane-scoped file routing * fix paste recovery before upload wait and blocked storage reads * fix new-chat draft clearing, paste name collisions, and stale composer uploads * keep the composer draft across late agent metadata refreshes * resolve paste anchors by their unique intact junction * anchor paste recovery to the junction nearest the captured caret * honor the paste setting before file config lands and migrate pending drafts one copy at a time * route pastes past the pending file config and chunk large recovery encoding * sort imports in useAutoSave
This commit is contained in:
parent
e736fcfa09
commit
7ebf6b2548
28 changed files with 3567 additions and 167 deletions
|
|
@ -181,6 +181,7 @@ const ChatForm = memo(function ChatForm({
|
|||
: (answerMode.otherLabel ?? localize('com_ui_something_else'));
|
||||
|
||||
useAutoSave({
|
||||
index,
|
||||
files,
|
||||
setFiles,
|
||||
textAreaRef,
|
||||
|
|
@ -396,6 +397,7 @@ const ChatForm = memo(function ChatForm({
|
|||
// Enter stays live during a run when it can steer/queue instead of send.
|
||||
allowSubmitWhileGenerating: steering.duringRunActive,
|
||||
onDuringRunModifier: steering.duringRunActive ? handleDuringRunModifier : undefined,
|
||||
answerModeActive: answerMode.active && !answerMode.batchMode,
|
||||
});
|
||||
|
||||
useQueryParams({ textAreaRef });
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default function FileRow({
|
|||
Wrapper,
|
||||
}: {
|
||||
files: Map<string, ExtendedFile> | undefined;
|
||||
abortUpload?: () => void;
|
||||
abortUpload?: (fileId?: string) => void;
|
||||
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
|
||||
setFilesLoading?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fileFilter?: (file: ExtendedFile) => boolean;
|
||||
|
|
@ -122,7 +122,7 @@ export default function FileRow({
|
|||
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
||||
const handleDelete = () => {
|
||||
if (abortUpload && file.progress < 1) {
|
||||
abortUpload();
|
||||
abortUpload(file.file_id);
|
||||
}
|
||||
if (file.progress >= 1 && !file.attached) {
|
||||
showToast({
|
||||
|
|
|
|||
|
|
@ -204,6 +204,19 @@ export const registry: SettingEntry[] = [
|
|||
keywords: ['image', 'resize', 'compress', 'upload', 'attachment', 'photo'],
|
||||
Component: ImageResize,
|
||||
},
|
||||
{
|
||||
id: 'pasteLongTextAsFile',
|
||||
tab: CHAT,
|
||||
section: 'sending',
|
||||
labelKey: 'com_nav_paste_long_text_as_file',
|
||||
keywords: ['paste', 'clipboard', 'attachment', 'file', 'text'],
|
||||
Component: toggleControl({
|
||||
stateAtom: store.pasteLongTextAsFile,
|
||||
localizationKey: 'com_nav_paste_long_text_as_file',
|
||||
switchId: 'pasteLongTextAsFile',
|
||||
hoverCardText: 'com_nav_info_paste_long_text_as_file',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'saveBadgesState',
|
||||
tab: CHAT,
|
||||
|
|
|
|||
88
client/src/hooks/Agents/__tests__/useSelectAgent.spec.ts
Normal file
88
client/src/hooks/Agents/__tests__/useSelectAgent.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
|
||||
const mockNewConversation = jest.fn();
|
||||
const mockFetchQuery = jest.fn();
|
||||
const mockGetConversation = jest.fn();
|
||||
const mockGetDefaultConversation = jest.fn();
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: jest.fn(() => ({ fetchQuery: mockFetchQuery })),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/useNewConvo', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ newConversation: mockNewConversation })),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Conversations/useGetConversation', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => mockGetConversation),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Conversations/useDefaultConvo', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => mockGetDefaultConversation),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers/AgentsMapContext', () => ({
|
||||
useAgentsMapContext: jest.fn(() => ({ 'agent-1': { id: 'agent-1', name: 'Agent' } })),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({ logger: { log: jest.fn() } }));
|
||||
|
||||
import useSelectAgent from '../useSelectAgent';
|
||||
|
||||
describe('useSelectAgent', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetConversation.mockResolvedValue({ endpoint: EModelEndpoint.agents } as TConversation);
|
||||
mockGetDefaultConversation.mockImplementation(
|
||||
({ conversation }: { conversation: Partial<TConversation> }) => conversation,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a fresh composer first and keeps it when the agent details arrive', async () => {
|
||||
mockFetchQuery.mockResolvedValue({ id: 'agent-1', name: 'Full Agent' });
|
||||
const { result } = renderHook(() => useSelectAgent());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onSelect('agent-1');
|
||||
});
|
||||
|
||||
expect(mockNewConversation).toHaveBeenCalledTimes(2);
|
||||
expect(mockNewConversation.mock.calls[0][0].keepComposerState).toBe(false);
|
||||
expect(mockNewConversation.mock.calls[1][0].keepComposerState).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the composer when the agent details cannot be fetched', async () => {
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mockFetchQuery.mockRejectedValue(new Error('offline'));
|
||||
const { result } = renderHook(() => useSelectAgent());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onSelect('agent-1');
|
||||
});
|
||||
|
||||
expect(mockNewConversation).toHaveBeenCalledTimes(2);
|
||||
expect(mockNewConversation.mock.calls[1][0].keepComposerState).toBe(true);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the composer for the assistants path as well', async () => {
|
||||
mockGetConversation.mockResolvedValue({
|
||||
endpoint: EModelEndpoint.assistants,
|
||||
} as TConversation);
|
||||
mockFetchQuery.mockResolvedValue({ id: 'agent-1', name: 'Full Agent' });
|
||||
const { result } = renderHook(() => useSelectAgent());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onSelect('agent-1');
|
||||
});
|
||||
|
||||
expect(mockGetDefaultConversation).not.toHaveBeenCalled();
|
||||
expect(mockNewConversation.mock.calls[0][0].keepComposerState).toBe(false);
|
||||
expect(mockNewConversation.mock.calls[1][0].keepComposerState).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,13 +22,20 @@ export default function useSelectAgent() {
|
|||
const getConversation = useGetConversation(0);
|
||||
|
||||
const updateConversation = useCallback(
|
||||
async (agent: Partial<Agent>, template: Partial<TPreset | TConversation>) => {
|
||||
async (
|
||||
agent: Partial<Agent>,
|
||||
template: Partial<TPreset | TConversation>,
|
||||
/** The passes that follow the first one only carry freshly fetched agent details into the
|
||||
* composer the first pass opened, so a paste started meanwhile keeps its draft. */
|
||||
keepComposerState = false,
|
||||
) => {
|
||||
const conversation = await getConversation();
|
||||
logger.log('conversation', 'Updating conversation with agent', agent);
|
||||
if (isAssistantsEndpoint(conversation?.endpoint)) {
|
||||
newConversation({
|
||||
template: { ...(template as Partial<TConversation>) },
|
||||
preset: template as Partial<TPreset>,
|
||||
keepComposerState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -39,6 +46,7 @@ export default function useSelectAgent() {
|
|||
newConversation({
|
||||
template: currentConvo,
|
||||
preset: template as Partial<TPreset>,
|
||||
keepComposerState,
|
||||
});
|
||||
},
|
||||
[getConversation, getDefaultConversation, newConversation],
|
||||
|
|
@ -66,7 +74,7 @@ export default function useSelectAgent() {
|
|||
}),
|
||||
);
|
||||
if (fullAgent) {
|
||||
await updateConversation(fullAgent, { ...template, agent_id: fullAgent.id });
|
||||
await updateConversation(fullAgent, { ...template, agent_id: fullAgent.id }, true);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { silent: boolean } | undefined)?.silent) {
|
||||
|
|
@ -74,7 +82,7 @@ export default function useSelectAgent() {
|
|||
return;
|
||||
}
|
||||
console.error('Error fetching full agent data:', error);
|
||||
await updateConversation({}, { ...template, agent_id: undefined });
|
||||
await updateConversation({}, { ...template, agent_id: undefined }, true);
|
||||
}
|
||||
},
|
||||
[agentsMap, updateConversation, queryClient],
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
appendAppliedSteerIds,
|
||||
carriedSteerContext,
|
||||
clearAllDrafts,
|
||||
getPendingDraftId,
|
||||
insertQueuedOrigin,
|
||||
} from '~/utils';
|
||||
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
|
||||
|
|
@ -625,13 +626,14 @@ export default function useSteering({
|
|||
* a steer or queued item. The composer clears via the form's `reset()`,
|
||||
* which is programmatic and never fires the `input` event `useAutoSave`
|
||||
* listens on — so the draft would outlive the submit. It is keyed under
|
||||
* `PENDING_CONVO` here (every caller is gated on `duringRunActive`, which
|
||||
* requires `isSubmitting` and rules out the answer-mode draft key), and
|
||||
* run end migrates a surviving pending draft onto the conversation and
|
||||
* restores it — resurfacing text the user already sent. */
|
||||
* this pane's pending draft key here (every caller is gated on
|
||||
* `duringRunActive`, which requires `isSubmitting` and rules out the
|
||||
* answer-mode draft key), and run end migrates a surviving pending draft
|
||||
* onto the conversation and restores it: resurfacing text the user already
|
||||
* sent. */
|
||||
const takeComposerDraft = useCallback(() => {
|
||||
clearAllDrafts(Constants.PENDING_CONVO);
|
||||
}, []);
|
||||
clearAllDrafts(getPendingDraftId(index));
|
||||
}, [index]);
|
||||
|
||||
const removeQueued = useRecoilCallback(
|
||||
({ set }) =>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import useFileDeletion from '../useFileDeletion';
|
|||
|
||||
const mockMutateAsync = jest.fn();
|
||||
|
||||
jest.mock('../useFileHandling', () => ({
|
||||
clearUploadRecovery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useDeleteFilesMutation: () => ({ mutateAsync: mockMutateAsync }),
|
||||
}));
|
||||
|
|
@ -49,6 +53,8 @@ jest.mock('~/components/Chat/Input/Files/Image', () => {
|
|||
};
|
||||
});
|
||||
|
||||
const mockClearUploadRecovery = jest.requireMock('../useFileHandling').clearUploadRecovery;
|
||||
|
||||
/** Mirrors the shape `utils/forms.tsx` builds for agent Context/File Search panels */
|
||||
const makeFile = (file_id: string): ExtendedFile =>
|
||||
({
|
||||
|
|
@ -82,6 +88,7 @@ describe('useFileDeletion', () => {
|
|||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockMutateAsync.mockClear();
|
||||
mockClearUploadRecovery.mockClear();
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
|
|
@ -132,4 +139,17 @@ describe('useFileDeletion', () => {
|
|||
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears upload recovery before returning for a pending attachment', () => {
|
||||
const { result } = renderHook(() => useFileDeletion({ mutateAsync: mockMutateAsync }));
|
||||
|
||||
act(() => {
|
||||
result.current.deleteFile({
|
||||
file: { ...makeFile('pending-file'), progress: 0.5 },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockClearUploadRecovery).toHaveBeenCalledWith('pending-file');
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,21 @@ import {
|
|||
getEndpointFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
type MockUploadMutationOptions = {
|
||||
onSuccess?: (data: {
|
||||
temp_file_id: string;
|
||||
file_id: string;
|
||||
filepath: string;
|
||||
type: string;
|
||||
filename: string;
|
||||
source: string;
|
||||
embedded: boolean;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}) => void;
|
||||
onError?: (error: unknown, body: FormData) => void;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
|
|
@ -44,6 +59,7 @@ let mockConversation: Record<string, string | null | undefined> = {};
|
|||
let mockFileConfig: ReturnType<typeof mergeFileConfig> | null = null;
|
||||
let mockIsConfigPending = false;
|
||||
let mockIsTemporary = false;
|
||||
let mockUploadOptions: MockUploadMutationOptions = {};
|
||||
|
||||
jest.mock('~/Providers/ChatContext', () => ({
|
||||
useChatContext: jest.fn(() => ({
|
||||
|
|
@ -81,9 +97,10 @@ jest.mock('@tanstack/react-query', () => ({
|
|||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetFileConfig: jest.fn(() => ({ data: mockFileConfig })),
|
||||
useUploadFileMutation: jest.fn((_opts: Record<string, unknown>) => ({
|
||||
mutate: mockMutate,
|
||||
})),
|
||||
useUploadFileMutation: jest.fn((opts: MockUploadMutationOptions) => {
|
||||
mockUploadOptions = opts;
|
||||
return { mutate: mockMutate };
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/useLocalize', () => {
|
||||
|
|
@ -156,6 +173,7 @@ describe('useFileHandling', () => {
|
|||
mockFileConfig = null;
|
||||
mockIsConfigPending = false;
|
||||
mockIsTemporary = false;
|
||||
mockUploadOptions = {};
|
||||
});
|
||||
|
||||
const loadHook = async () => (await import('../useFileHandling')).default;
|
||||
|
|
@ -205,7 +223,7 @@ describe('useFileHandling', () => {
|
|||
const useFileHandling = await loadHook();
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
const imageFile = new File(['image'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
let handlingPromise: Promise<void> = Promise.resolve();
|
||||
let handlingPromise: Promise<boolean> = Promise.resolve(false);
|
||||
|
||||
await act(async () => {
|
||||
handlingPromise = result.current.handleFiles([imageFile]);
|
||||
|
|
@ -228,6 +246,45 @@ describe('useFileHandling', () => {
|
|||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('abandons a waiting upload when its composer is gone', async () => {
|
||||
let resolveConfig: () => void = () => undefined;
|
||||
mockIsConfigPending = true;
|
||||
mockWaitForConfig.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveConfig = resolve;
|
||||
}),
|
||||
);
|
||||
const { default: useFileHandling, hasInFlightUpload } = await import('../useFileHandling');
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
const pastedFile = makeSizedFile('pasted-text.txt', 'text/plain', 4096);
|
||||
let composerIsCurrent = true;
|
||||
let handlingPromise: Promise<boolean> = Promise.resolve(false);
|
||||
|
||||
await act(async () => {
|
||||
handlingPromise = result.current.handleFiles([pastedFile], undefined, {
|
||||
fileId: 'paste-1',
|
||||
shouldCommit: () => composerIsCurrent,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hasInFlightUpload('paste-1')).toBe(true);
|
||||
composerIsCurrent = false;
|
||||
let accepted: boolean | undefined;
|
||||
|
||||
await act(async () => {
|
||||
resolveConfig();
|
||||
accepted = await handlingPromise;
|
||||
});
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
expect(hasInFlightUpload('paste-1')).toBe(false);
|
||||
expect(mockValidateFiles).not.toHaveBeenCalled();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
expect(mockSetFilesLoading).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('processes uploads when a resize config error has settled', async () => {
|
||||
mockIsConfigPending = false;
|
||||
const useFileHandling = await loadHook();
|
||||
|
|
@ -450,7 +507,7 @@ describe('useFileHandling', () => {
|
|||
const menu = renderHook(() => useFileHandlingNoChatContext(undefined, sharedState));
|
||||
const composer = renderHook(() => useFileHandlingNoChatContext(undefined, sharedState));
|
||||
|
||||
let uploads: Promise<void[]> = Promise.resolve([]);
|
||||
let uploads: Promise<boolean[]> = Promise.resolve([]);
|
||||
await act(async () => {
|
||||
uploads = Promise.all([
|
||||
menu.result.current.handleFiles([makeSizedFile('one.txt', 'text/plain', 1024)]),
|
||||
|
|
@ -873,4 +930,234 @@ describe('useFileHandling', () => {
|
|||
expect(uploadedFile.type).toBe('image/jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
/** Callers gate success messaging on this result, so a rejected upload must not report true */
|
||||
describe('acceptance result', () => {
|
||||
it('resolves false when validation rejects the files', async () => {
|
||||
mockValidateFiles.mockImplementationOnce(() => false);
|
||||
|
||||
const useFileHandling = await loadHook();
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
|
||||
let accepted: boolean | undefined;
|
||||
await act(async () => {
|
||||
accepted = await result.current.handleFiles([
|
||||
new File(['hello'], 'dupe.txt', { type: 'text/plain' }),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves false when validation throws', async () => {
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mockValidateFiles.mockImplementationOnce(() => {
|
||||
throw new Error('invalid file config');
|
||||
});
|
||||
|
||||
const useFileHandling = await loadHook();
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
|
||||
let accepted: boolean | undefined;
|
||||
await act(async () => {
|
||||
accepted = await result.current.handleFiles([
|
||||
new File(['hello'], 'test.txt', { type: 'text/plain' }),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('resolves true when the files are accepted', async () => {
|
||||
const useFileHandling = await loadHook();
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
|
||||
let accepted: boolean | undefined;
|
||||
await act(async () => {
|
||||
accepted = await result.current.handleFiles([
|
||||
new File(['hello'], 'notes.txt', { type: 'text/plain' }),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(accepted).toBe(true);
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses a preassigned file id and marks it in flight before the config wait', async () => {
|
||||
mockIsConfigPending = true;
|
||||
let releaseConfig!: () => void;
|
||||
mockWaitForConfig.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseConfig = resolve;
|
||||
}),
|
||||
);
|
||||
const assignedFileId = 'preassigned-file';
|
||||
const { default: useFileHandling, hasInFlightUpload } = await import('../useFileHandling');
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
|
||||
let acceptedPromise!: Promise<boolean>;
|
||||
act(() => {
|
||||
acceptedPromise = result.current.handleFiles(
|
||||
[new File(['hello'], 'notes.txt', { type: 'text/plain' })],
|
||||
undefined,
|
||||
{ fileId: assignedFileId },
|
||||
);
|
||||
});
|
||||
|
||||
expect(hasInFlightUpload(assignedFileId)).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
releaseConfig();
|
||||
await acceptedPromise;
|
||||
});
|
||||
|
||||
const uploadBody = mockMutate.mock.calls[0][0] as FormData;
|
||||
expect(uploadBody.get('file_id')).toBe(assignedFileId);
|
||||
});
|
||||
|
||||
it('clears a preassigned file id when validation rejects the files', async () => {
|
||||
mockValidateFiles.mockImplementationOnce(() => false);
|
||||
const assignedFileId = 'rejected-preassigned-file';
|
||||
const { default: useFileHandling, hasInFlightUpload } = await import('../useFileHandling');
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
|
||||
let accepted: boolean | undefined;
|
||||
await act(async () => {
|
||||
accepted = await result.current.handleFiles(
|
||||
[new File(['hello'], 'notes.txt', { type: 'text/plain' })],
|
||||
undefined,
|
||||
{ fileId: assignedFileId },
|
||||
);
|
||||
});
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
expect(hasInFlightUpload(assignedFileId)).toBe(false);
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the temporary id at upload start and success', async () => {
|
||||
const onStart = jest.fn();
|
||||
const onSuccess = jest.fn();
|
||||
const useFileHandling = await loadHook();
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFiles(
|
||||
[new File(['hello'], 'notes.txt', { type: 'text/plain' })],
|
||||
undefined,
|
||||
{ onStart, onSuccess },
|
||||
);
|
||||
});
|
||||
|
||||
const uploadBody = mockMutate.mock.calls[0][0] as FormData;
|
||||
const fileId = uploadBody.get('file_id') as string;
|
||||
expect(onStart).toHaveBeenCalledWith(fileId);
|
||||
|
||||
act(() => {
|
||||
mockUploadOptions.onSuccess?.({
|
||||
temp_file_id: fileId,
|
||||
file_id: 'saved-file-id',
|
||||
filepath: '/files/notes.txt',
|
||||
type: 'text/plain',
|
||||
filename: 'notes.txt',
|
||||
source: 'local',
|
||||
embedded: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith(fileId);
|
||||
});
|
||||
|
||||
it('resolves false when every file fails preprocessing', async () => {
|
||||
const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
mockProcessFileForUpload.mockRejectedValue(new Error('HEIC conversion failed'));
|
||||
|
||||
const useFileHandling = await loadHook();
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
|
||||
let accepted: boolean | undefined;
|
||||
await act(async () => {
|
||||
accepted = await result.current.handleFiles([
|
||||
new File(['first'], 'first.heic', { type: 'image/heic' }),
|
||||
new File(['second'], 'second.heic', { type: 'image/heic' }),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
expect(mockProcessFileForUpload).toHaveBeenCalledTimes(2);
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
|
||||
it('runs the matching recovery when the first of two concurrent uploads fails', async () => {
|
||||
const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const firstRecovery = jest.fn();
|
||||
const secondRecovery = jest.fn();
|
||||
const useFileHandling = await loadHook();
|
||||
const { result } = renderHook(() => useFileHandling());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFiles(
|
||||
[new File(['first'], 'pasted-text.txt', { type: 'text/plain' })],
|
||||
undefined,
|
||||
{ onError: firstRecovery },
|
||||
);
|
||||
await result.current.handleFiles(
|
||||
[new File(['second'], 'pasted-text-2.txt', { type: 'text/plain' })],
|
||||
undefined,
|
||||
{ onError: secondRecovery },
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(2);
|
||||
const firstUploadBody = mockMutate.mock.calls[0][0] as FormData;
|
||||
|
||||
act(() => mockUploadOptions.onError?.(new Error('first upload failed'), firstUploadBody));
|
||||
|
||||
expect(firstRecovery).toHaveBeenCalledTimes(1);
|
||||
expect(secondRecovery).not.toHaveBeenCalled();
|
||||
|
||||
act(() => mockUploadOptions.onError?.(new Error('first upload failed'), firstUploadBody));
|
||||
expect(firstRecovery).toHaveBeenCalledTimes(1);
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
|
||||
it('does not recover pasted text after a separate file form removes the pending attachment', async () => {
|
||||
const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const recovery = jest.fn();
|
||||
const { default: useFileHandling, useFileHandlingNoChatContext } = await import(
|
||||
'../useFileHandling'
|
||||
);
|
||||
const { result: uploadResult } = renderHook(() => useFileHandling());
|
||||
|
||||
await act(async () => {
|
||||
await uploadResult.current.handleFiles(
|
||||
[new File(['pasted text'], 'pasted-text.txt', { type: 'text/plain' })],
|
||||
undefined,
|
||||
{ onError: recovery },
|
||||
);
|
||||
});
|
||||
|
||||
const uploadBody = mockMutate.mock.calls[0][0] as FormData;
|
||||
const uploadOptions = mockUploadOptions;
|
||||
const fileId = uploadBody.get('file_id') as string;
|
||||
const { result: removalResult } = renderHook(() =>
|
||||
useFileHandlingNoChatContext(undefined, {
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
removalResult.current.abortUpload(fileId);
|
||||
});
|
||||
act(() => uploadOptions.onError?.(new Error('upload failed after removal'), uploadBody));
|
||||
|
||||
expect(recovery).not.toHaveBeenCalled();
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useRef, useMemo, useCallback } from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { NativeTypes } from 'react-dnd-html5-backend';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -13,17 +12,17 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import type { DropTargetMonitor } from 'react-dnd';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import useFileUploadRouter from './useFileUploadRouter';
|
||||
import { useUploadModalContext } from '~/Providers';
|
||||
import useUploadOptions from './useUploadOptions';
|
||||
import useLocalize from '../useLocalize';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useDragHelpers() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
const isAssistants = useMemo(
|
||||
() => isAssistantsEndpoint(conversation?.endpoint),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { UseMutateAsyncFunction } from '@tanstack/react-query';
|
|||
import type * as t from 'librechat-data-provider';
|
||||
import type { ExtendedFile, GenericSetter } from '~/common';
|
||||
import useSetFilesToDelete from './useSetFilesToDelete';
|
||||
import { clearUploadRecovery } from './useFileHandling';
|
||||
import { deletePreview } from '~/utils';
|
||||
|
||||
type FileMapSetter = GenericSetter<Map<string, ExtendedFile>>;
|
||||
|
|
@ -67,6 +68,11 @@ const useFileDeletion = ({
|
|||
attached = false,
|
||||
} = _file as t.TFile & { attached?: boolean };
|
||||
|
||||
clearUploadRecovery(file_id);
|
||||
if (temp_file_id) {
|
||||
clearUploadRecovery(temp_file_id);
|
||||
}
|
||||
|
||||
const progress = _file['progress'] ?? 1;
|
||||
|
||||
if (progress < 1) {
|
||||
|
|
@ -125,6 +131,11 @@ const useFileDeletion = ({
|
|||
source = FileSources.local,
|
||||
} = _file;
|
||||
|
||||
clearUploadRecovery(file_id);
|
||||
if (temp_file_id) {
|
||||
clearUploadRecovery(temp_file_id);
|
||||
}
|
||||
|
||||
batchFiles.push({
|
||||
source,
|
||||
file_id,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,33 @@ type ProcessedUpload = {
|
|||
};
|
||||
};
|
||||
|
||||
export type UploadLifecycleCallbacks = {
|
||||
/** Preassigned id so callers can persist recovery before the shared upload queue waits. */
|
||||
fileId?: string;
|
||||
/** Read once the queue and config waits are over, immediately before the batch is written into
|
||||
* the shared file state. A `false` return abandons the batch so a delayed upload cannot land in
|
||||
* a composer the user has since navigated away from. */
|
||||
shouldCommit?: () => boolean;
|
||||
onStart?: (fileId: string) => void;
|
||||
onSuccess?: (fileId: string) => void;
|
||||
onError?: (fileId: string) => void;
|
||||
onAbort?: (fileId: string) => void;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
const uploadErrorCallbacks = new Map<string, UploadLifecycleCallbacks>();
|
||||
|
||||
const takeUploadRecovery = (fileId: string): UploadLifecycleCallbacks | undefined => {
|
||||
const callbacks = uploadErrorCallbacks.get(fileId);
|
||||
uploadErrorCallbacks.delete(fileId);
|
||||
return callbacks;
|
||||
};
|
||||
|
||||
export const clearUploadRecovery = (fileId: string) => {
|
||||
takeUploadRecovery(fileId)?.onAbort?.(fileId);
|
||||
};
|
||||
|
||||
export const hasInFlightUpload = (fileId: string): boolean => uploadErrorCallbacks.has(fileId);
|
||||
|
||||
type UploadScope = {
|
||||
queue: Promise<void>;
|
||||
|
|
@ -189,6 +215,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
const uploadFile = useUploadFileMutation(
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
takeUploadRecovery(data.temp_file_id)?.onSuccess?.(data.temp_file_id);
|
||||
clearUploadTimer(data.temp_file_id);
|
||||
console.log('upload success', data);
|
||||
if (agent_id) {
|
||||
|
|
@ -231,7 +258,8 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
onError: (_error, body) => {
|
||||
const error = _error as TError | undefined;
|
||||
console.log('upload error', error);
|
||||
const file_id = body.get('file_id');
|
||||
const file_id = body.get('file_id') as string;
|
||||
const uploadLifecycle = takeUploadRecovery(file_id);
|
||||
const tool_resource = body.get('tool_resource');
|
||||
if (tool_resource === EToolResources.execute_code) {
|
||||
setEphemeralAgent((prev) => ({
|
||||
|
|
@ -239,8 +267,8 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
[EToolResources.execute_code]: false,
|
||||
}));
|
||||
}
|
||||
clearUploadTimer(file_id as string);
|
||||
deleteFileById(file_id as string);
|
||||
clearUploadTimer(file_id);
|
||||
deleteFileById(file_id);
|
||||
|
||||
let errorMessage = 'com_error_files_upload';
|
||||
|
||||
|
|
@ -250,12 +278,28 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
errorMessage = error.response.data.message;
|
||||
}
|
||||
setError(errorMessage);
|
||||
uploadLifecycle?.onError?.(file_id);
|
||||
},
|
||||
},
|
||||
abortControllerRef.current?.signal,
|
||||
);
|
||||
|
||||
const startUpload = async (extendedFile: ExtendedFile) => {
|
||||
const uploadWithRecovery = (
|
||||
formData: FormData,
|
||||
file_id: string,
|
||||
uploadLifecycle?: UploadLifecycleCallbacks,
|
||||
) => {
|
||||
if (uploadLifecycle) {
|
||||
uploadErrorCallbacks.set(file_id, uploadLifecycle);
|
||||
uploadLifecycle.onStart?.(file_id);
|
||||
}
|
||||
uploadFile.mutate(formData);
|
||||
};
|
||||
|
||||
const startUpload = async (
|
||||
extendedFile: ExtendedFile,
|
||||
uploadLifecycle?: UploadLifecycleCallbacks,
|
||||
) => {
|
||||
const filename = extendedFile.file?.name ?? 'File';
|
||||
startUploadTimer(extendedFile.file_id, filename, extendedFile.size);
|
||||
|
||||
|
|
@ -305,7 +349,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
formData.append('agent_id', conversation.agent_id);
|
||||
}
|
||||
|
||||
uploadFile.mutate(formData);
|
||||
uploadWithRecovery(formData, extendedFile.file_id, uploadLifecycle);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -335,10 +379,14 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
formData.append('model', convoModel);
|
||||
}
|
||||
|
||||
uploadFile.mutate(formData);
|
||||
uploadWithRecovery(formData, extendedFile.file_id, uploadLifecycle);
|
||||
};
|
||||
|
||||
const loadImage = (extendedFile: ExtendedFile, preview: string) => {
|
||||
const loadImage = (
|
||||
extendedFile: ExtendedFile,
|
||||
preview: string,
|
||||
uploadLifecycle?: UploadLifecycleCallbacks,
|
||||
) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
extendedFile.width = img.width;
|
||||
|
|
@ -349,12 +397,17 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
};
|
||||
replaceFile(extendedFile);
|
||||
|
||||
await startUpload(extendedFile);
|
||||
await startUpload(extendedFile, uploadLifecycle);
|
||||
};
|
||||
img.src = preview;
|
||||
};
|
||||
|
||||
const processFiles = async (fileList: File[], _toolResource?: string) => {
|
||||
/** Resolves to whether the files passed validation and were accepted for upload. */
|
||||
const processFiles = async (
|
||||
fileList: File[],
|
||||
_toolResource?: string,
|
||||
uploadLifecycle?: UploadLifecycleCallbacks,
|
||||
): Promise<boolean> => {
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const existingFiles = tracksReservations
|
||||
|
|
@ -383,17 +436,20 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
console.error('file validation error', error);
|
||||
setError('com_error_files_validation');
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!filesAreValid) {
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Process files */
|
||||
const processedUploads: ProcessedUpload[] = [];
|
||||
for (const originalFile of fileList) {
|
||||
const file_id = v4();
|
||||
for (const [fileIndex, originalFile] of fileList.entries()) {
|
||||
const file_id =
|
||||
fileIndex === 0 && uploadLifecycle?.fileId != null && uploadLifecycle.fileId !== ''
|
||||
? uploadLifecycle.fileId
|
||||
: v4();
|
||||
try {
|
||||
// Create initial preview with original file
|
||||
const initialPreview = URL.createObjectURL(originalFile);
|
||||
|
|
@ -538,12 +594,12 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
setError('com_error_files_validation');
|
||||
discardProcessedUploads();
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!batchIsValid) {
|
||||
discardProcessedUploads();
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const filesWithProcessedUploads = new Map(existingFiles);
|
||||
|
|
@ -570,17 +626,27 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
}
|
||||
|
||||
if (extendedFile.file?.type.startsWith('image/') === true) {
|
||||
loadImage(extendedFile, preview);
|
||||
loadImage(extendedFile, preview, uploadLifecycle);
|
||||
continue;
|
||||
}
|
||||
|
||||
await startUpload(extendedFile);
|
||||
await startUpload(extendedFile, uploadLifecycle);
|
||||
}
|
||||
|
||||
return processedUploads.length > 0;
|
||||
};
|
||||
|
||||
const handleFiles = async (_files: FileList | File[], _toolResource?: string) => {
|
||||
const handleFiles = async (
|
||||
_files: FileList | File[],
|
||||
_toolResource?: string,
|
||||
uploadLifecycle?: UploadLifecycleCallbacks,
|
||||
): Promise<boolean> => {
|
||||
/** `FileList` is live: copy it before yielding, as callers reset the input synchronously */
|
||||
const fileList = Array.from(_files);
|
||||
const assignedFileId = uploadLifecycle?.fileId;
|
||||
if (assignedFileId) {
|
||||
uploadErrorCallbacks.set(assignedFileId, uploadLifecycle);
|
||||
}
|
||||
/** Started before queueing so every waiting batch shares one bounded config window */
|
||||
const configReady = isConfigPending ? waitForConfig() : undefined;
|
||||
const previousProcessing = uploadScope.queue;
|
||||
|
|
@ -589,10 +655,26 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
releaseProcessing = resolve;
|
||||
});
|
||||
|
||||
await previousProcessing;
|
||||
try {
|
||||
await previousProcessing;
|
||||
await configReady;
|
||||
await processFiles(fileList, _toolResource);
|
||||
if (uploadLifecycle?.shouldCommit?.() === false) {
|
||||
if (assignedFileId) {
|
||||
takeUploadRecovery(assignedFileId);
|
||||
}
|
||||
setFilesLoading(false);
|
||||
return false;
|
||||
}
|
||||
const accepted = await processFiles(fileList, _toolResource, uploadLifecycle);
|
||||
if (!accepted && assignedFileId) {
|
||||
takeUploadRecovery(assignedFileId);
|
||||
}
|
||||
return accepted;
|
||||
} catch (error) {
|
||||
if (assignedFileId) {
|
||||
takeUploadRecovery(assignedFileId);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
releaseProcessing();
|
||||
}
|
||||
|
|
@ -608,12 +690,19 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
}
|
||||
};
|
||||
|
||||
const abortUpload = () => {
|
||||
const abortUpload = (fileId?: string) => {
|
||||
if (abortControllerRef.current) {
|
||||
logger.log('files', 'Aborting upload');
|
||||
abortControllerRef.current.abort('User aborted upload');
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
if (fileId) {
|
||||
clearUploadRecovery(fileId);
|
||||
return;
|
||||
}
|
||||
for (const uploadId of Array.from(uploadErrorCallbacks.keys())) {
|
||||
clearUploadRecovery(uploadId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,33 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { Constants, EToolResources } from 'librechat-data-provider';
|
||||
import store, { ephemeralAgentByConvoId } from '~/store';
|
||||
import type { UploadLifecycleCallbacks } from './useFileHandling';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import useFileHandling from './useFileHandling';
|
||||
|
||||
/**
|
||||
* Returns a function that attaches files to a chosen upload destination, enabling the
|
||||
* matching ephemeral-agent capability first (file search is left for explicit opt-in to
|
||||
* preserve legacy behavior). Shared by the paste, drag, and modal flows.
|
||||
* preserve legacy behavior). Shared by the paste, drag, and modal flows. Resolves to
|
||||
* whether the files were accepted, so callers can gate success messaging on it.
|
||||
*/
|
||||
export default function useFileUploadRouter() {
|
||||
const { handleFiles } = useFileHandling();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
|
||||
const { conversation } = useChatContext();
|
||||
const setEphemeralAgent = useSetRecoilState(
|
||||
ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO),
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(files: File[], toolResource?: EToolResources) => {
|
||||
(files: File[], toolResource?: EToolResources, uploadLifecycle?: UploadLifecycleCallbacks) => {
|
||||
if (toolResource && toolResource !== EToolResources.file_search) {
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
[toolResource]: true,
|
||||
}));
|
||||
}
|
||||
handleFiles(files, toolResource);
|
||||
return handleFiles(files, toolResource, uploadLifecycle);
|
||||
},
|
||||
[handleFiles, setEphemeralAgent],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,9 +30,16 @@ export default function useUploadOptions() {
|
|||
ephemeralAgentByConvoId(conversationId ?? Constants.NEW_CONVO),
|
||||
);
|
||||
const { provider, tools } = useAgentToolPermissions(agentId, ephemeralAgent);
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
const {
|
||||
data: fileConfig = null,
|
||||
isError: isFileConfigError,
|
||||
isPaused: isFileConfigPaused,
|
||||
isSuccess: isFileConfigLoaded,
|
||||
} = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
/** Destination checks read this config, so callers can tell "not viable" from "not known yet". */
|
||||
const isConfigPending = !isFileConfigLoaded && !isFileConfigError && !isFileConfigPaused;
|
||||
|
||||
/**
|
||||
* Tools are offerable unless a saved agent omits them; in direct/ephemeral chats selecting
|
||||
|
|
@ -76,5 +83,5 @@ export default function useUploadOptions() {
|
|||
],
|
||||
);
|
||||
|
||||
return { getOptions, uploadsDisabled };
|
||||
return { getOptions, uploadsDisabled, isConfigPending };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ jest.mock('~/data-provider', () => ({
|
|||
useGetFiles: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Files/useFileHandling', () => ({
|
||||
hasInFlightUpload: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
...jest.requireActual('~/utils'),
|
||||
getDraft: jest.fn(),
|
||||
|
|
@ -29,7 +33,15 @@ import { useRecoilValue } from 'recoil';
|
|||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { useChatFormContext } from '~/Providers';
|
||||
import { useGetFiles } from '~/data-provider';
|
||||
import { encodeBase64, getAskAnswerDraftId, getDraft, setDraft } from '~/utils';
|
||||
import { hasInFlightUpload } from '~/hooks/Files/useFileHandling';
|
||||
import {
|
||||
encodeBase64,
|
||||
getAskAnswerDraftId,
|
||||
getDraft,
|
||||
getFilesDraft,
|
||||
setDraft,
|
||||
setFilesDraft,
|
||||
} from '~/utils';
|
||||
import store from '~/store';
|
||||
import { useAutoSave } from '~/hooks';
|
||||
|
||||
|
|
@ -43,12 +55,14 @@ const makeTextAreaRef = (value = '') =>
|
|||
}) as unknown as React.RefObject<HTMLTextAreaElement>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
(useRecoilValue as jest.Mock).mockImplementation((atom) => {
|
||||
if (atom === store.saveDrafts) return true;
|
||||
return undefined;
|
||||
});
|
||||
(useChatFormContext as jest.Mock).mockReturnValue({ setValue: mockSetValue });
|
||||
(useGetFiles as jest.Mock).mockReturnValue({ data: [] });
|
||||
(hasInFlightUpload as jest.Mock).mockReturnValue(false);
|
||||
mockGetDraft.mockReturnValue('');
|
||||
});
|
||||
|
||||
|
|
@ -108,6 +122,125 @@ describe('useAutoSave — conversation switching', () => {
|
|||
|
||||
expect(mockSetDraft).toHaveBeenCalledWith({ id: 'convo-1', value: 'draft in progress' });
|
||||
});
|
||||
|
||||
it('restores an incomplete pasted-text upload into the composer after reload', () => {
|
||||
mockGetDraft.mockReturnValue('before after');
|
||||
setFilesDraft('convo-1', {
|
||||
fileIds: ['pending-paste-file'],
|
||||
pendingPastes: {
|
||||
'pending-paste-file': {
|
||||
text: 'recovered pasted text',
|
||||
selectionStart: 7,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSave({
|
||||
conversationId: 'convo-1',
|
||||
textAreaRef: makeTextAreaRef(),
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'before recovered pasted text after');
|
||||
expect(mockSetDraft).toHaveBeenCalledWith({
|
||||
id: 'convo-1',
|
||||
value: 'before recovered pasted text after',
|
||||
});
|
||||
expect(getFilesDraft('convo-1')).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
});
|
||||
|
||||
it('does not recover a paste whose upload is still in flight', () => {
|
||||
(hasInFlightUpload as jest.Mock).mockReturnValue(true);
|
||||
mockGetDraft.mockReturnValue('before after');
|
||||
setFilesDraft('convo-1', {
|
||||
fileIds: ['pending-paste-file'],
|
||||
pendingPastes: {
|
||||
'pending-paste-file': {
|
||||
text: 'recovered pasted text',
|
||||
selectionStart: 7,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSave({
|
||||
conversationId: 'convo-1',
|
||||
textAreaRef: makeTextAreaRef(),
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'before after');
|
||||
expect(getFilesDraft('convo-1').pendingPastes['pending-paste-file']?.text).toBe(
|
||||
'recovered pasted text',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a stale selected range when recovering a pending paste after reload', () => {
|
||||
mockGetDraft.mockReturnValue('before selected after');
|
||||
setFilesDraft('convo-1', {
|
||||
fileIds: ['pending-paste-file'],
|
||||
pendingPastes: {
|
||||
'pending-paste-file': {
|
||||
text: 'recovered pasted text',
|
||||
selectionStart: 7,
|
||||
selectionEnd: 15,
|
||||
replacedText: 'selected',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSave({
|
||||
conversationId: 'convo-1',
|
||||
textAreaRef: makeTextAreaRef(),
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'before recovered pasted text after');
|
||||
expect(mockSetDraft).toHaveBeenCalledWith({
|
||||
id: 'convo-1',
|
||||
value: 'before recovered pasted text after',
|
||||
});
|
||||
});
|
||||
|
||||
it('rebases two pending replacements so the earlier paste is not restored stale', () => {
|
||||
mockGetDraft.mockReturnValue('AAAA BBBB CCCC');
|
||||
setFilesDraft('convo-1', {
|
||||
fileIds: ['end-file', 'start-file'],
|
||||
pendingPastes: {
|
||||
'end-file': {
|
||||
text: 'END',
|
||||
selectionStart: 10,
|
||||
replacedText: 'CCCC',
|
||||
sequence: 1,
|
||||
},
|
||||
'start-file': {
|
||||
text: 'START',
|
||||
selectionStart: 0,
|
||||
replacedText: 'AAAA',
|
||||
sequence: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSave({
|
||||
conversationId: 'convo-1',
|
||||
textAreaRef: makeTextAreaRef(),
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'START BBBB END');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAutoSave — ask-answer draft swap', () => {
|
||||
|
|
@ -279,3 +412,120 @@ describe('useAutoSave — debounced autosave', () => {
|
|||
expect(mockSetDraft).toHaveBeenLastCalledWith({ id: 'convo-1', value: 'still typing' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAutoSave — side-by-side pending drafts', () => {
|
||||
const pane0PendingId = Constants.PENDING_CONVO;
|
||||
const pane1PendingId = `${Constants.PENDING_CONVO}:1`;
|
||||
|
||||
it('migrates only this pane pending file draft when a run finishes', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ isSubmitting }: { isSubmitting: boolean }) =>
|
||||
useAutoSave({
|
||||
index: 1,
|
||||
isSubmitting,
|
||||
conversationId: 'convo-side',
|
||||
textAreaRef: makeTextAreaRef(),
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
}),
|
||||
{ initialProps: { isSubmitting: true } },
|
||||
);
|
||||
|
||||
setFilesDraft(pane0PendingId, {
|
||||
fileIds: ['pane-0-file'],
|
||||
pendingPastes: {
|
||||
'pane-0-file': { text: 'pane 0 paste', selectionStart: 0 },
|
||||
},
|
||||
});
|
||||
setFilesDraft(pane1PendingId, {
|
||||
fileIds: ['pane-1-file'],
|
||||
pendingPastes: {
|
||||
'pane-1-file': { text: 'pane 1 paste', selectionStart: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rerender({ isSubmitting: false });
|
||||
});
|
||||
|
||||
expect(getFilesDraft(pane0PendingId).pendingPastes['pane-0-file']?.text).toBe('pane 0 paste');
|
||||
expect(getFilesDraft(pane1PendingId)).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'pane 1 paste');
|
||||
});
|
||||
|
||||
it('recovers a pending paste the destination key has no room for', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ isSubmitting }: { isSubmitting: boolean }) =>
|
||||
useAutoSave({
|
||||
index: 1,
|
||||
isSubmitting,
|
||||
conversationId: 'convo-side',
|
||||
textAreaRef: makeTextAreaRef(),
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
}),
|
||||
{ initialProps: { isSubmitting: true } },
|
||||
);
|
||||
|
||||
setFilesDraft(pane1PendingId, {
|
||||
fileIds: ['pane-1-file'],
|
||||
pendingPastes: {
|
||||
'pane-1-file': { text: 'pane 1 paste', selectionStart: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const realSetItem = Storage.prototype.setItem;
|
||||
const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
|
||||
this: Storage,
|
||||
key: string,
|
||||
value: string,
|
||||
) {
|
||||
if (key === `${LocalStorageKeys.FILES_DRAFT}convo-side`) {
|
||||
throw new Error('quota exceeded');
|
||||
}
|
||||
realSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rerender({ isSubmitting: false });
|
||||
});
|
||||
|
||||
setItem.mockRestore();
|
||||
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'pane 1 paste');
|
||||
expect(getFilesDraft(pane1PendingId)).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
});
|
||||
|
||||
it('restores only this pane idle unsaved file draft', () => {
|
||||
mockGetDraft.mockImplementation((id: string) =>
|
||||
id === `${Constants.NEW_CONVO}:1` ? 'pane 1 draft' : 'pane 0 draft',
|
||||
);
|
||||
setFilesDraft(Constants.NEW_CONVO, {
|
||||
fileIds: ['pane-0-file'],
|
||||
pendingPastes: {
|
||||
'pane-0-file': { text: 'pane 0 paste', selectionStart: 0 },
|
||||
},
|
||||
});
|
||||
setFilesDraft(`${Constants.NEW_CONVO}:1`, {
|
||||
fileIds: ['pane-1-file'],
|
||||
pendingPastes: {
|
||||
'pane-1-file': { text: 'pane 1 paste', selectionStart: 12 },
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSave({
|
||||
index: 1,
|
||||
conversationId: Constants.NEW_CONVO as string,
|
||||
textAreaRef: makeTextAreaRef(),
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'pane 1 draftpane 1 paste');
|
||||
expect(getFilesDraft(Constants.NEW_CONVO).pendingPastes['pane-0-file']?.text).toBe(
|
||||
'pane 0 paste',
|
||||
);
|
||||
expect(getFilesDraft(`${Constants.NEW_CONVO}:1`)).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,31 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { SetterOrUpdater, useRecoilValue } from 'recoil';
|
||||
import { LocalStorageKeys, Constants } from 'librechat-data-provider';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { PendingTextAttachmentDraft } from '~/utils';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { clearDraft, getDraft, isAskAnswerDraftId, setDraft } from '~/utils';
|
||||
import {
|
||||
applyPendingPastesToDraft,
|
||||
clearDraft,
|
||||
getDraft,
|
||||
getFilesDraft,
|
||||
getNewConversationDraftId,
|
||||
getPendingDraftId,
|
||||
isAskAnswerDraftId,
|
||||
isNewConversationDraftId,
|
||||
migrateFilesDraft,
|
||||
migrateTextDraft,
|
||||
setDraft,
|
||||
setFilesDraft,
|
||||
} from '~/utils';
|
||||
import { hasInFlightUpload } from '~/hooks/Files/useFileHandling';
|
||||
import { useChatFormContext } from '~/Providers';
|
||||
import { useGetFiles } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
export const useAutoSave = ({
|
||||
index = 0,
|
||||
isSubmitting,
|
||||
conversationId: _conversationId,
|
||||
draftId,
|
||||
|
|
@ -17,9 +33,10 @@ export const useAutoSave = ({
|
|||
setFiles,
|
||||
files,
|
||||
}: {
|
||||
index?: number;
|
||||
isSubmitting?: boolean;
|
||||
conversationId?: string | null;
|
||||
/** Explicit draft-key override — wins over the conversation id AND the
|
||||
/** Explicit draft-key override: wins over the conversation id AND the
|
||||
* PENDING_CONVO redirect. Set while an `ask_user_question` pause turns the
|
||||
* composer into the answer box: the answer phase drafts under its own key,
|
||||
* and the key change itself drives the save/restore swap below, so the
|
||||
|
|
@ -33,26 +50,37 @@ export const useAutoSave = ({
|
|||
// setting for auto-save
|
||||
const { setValue } = useChatFormContext();
|
||||
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
|
||||
const conversationId = draftId ?? (isSubmitting ? Constants.PENDING_CONVO : _conversationId);
|
||||
const pendingDraftId = getPendingDraftId(index);
|
||||
const conversationDraftId =
|
||||
_conversationId === Constants.NEW_CONVO ? getNewConversationDraftId(index) : _conversationId;
|
||||
const conversationId = draftId ?? (isSubmitting ? pendingDraftId : conversationDraftId);
|
||||
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
||||
const fileIds = useMemo(() => Array.from(files.keys()), [files]);
|
||||
const { data: fileList } = useGetFiles<TFile[]>();
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
|
||||
const restoreFiles = useCallback(
|
||||
(id: string) => {
|
||||
const filesDraft = JSON.parse(
|
||||
(localStorage.getItem(`${LocalStorageKeys.FILES_DRAFT}${id}`) ?? '') || '[]',
|
||||
) as string[];
|
||||
(id: string): PendingTextAttachmentDraft[] => {
|
||||
const filesDraft = getFilesDraft(id);
|
||||
|
||||
if (filesDraft.length === 0) {
|
||||
if (filesDraft.fileIds.length === 0) {
|
||||
setFiles(new Map());
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
if (fileList == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeFileIds = new Set(filesRef.current.keys());
|
||||
const fileIdsToKeep: string[] = [];
|
||||
const pendingPastes = { ...filesDraft.pendingPastes };
|
||||
const pastesToRecover: PendingTextAttachmentDraft[] = [];
|
||||
|
||||
// Retrieve files stored in localStorage from files in fileList and set them to `setFiles`
|
||||
// If a file is found with `temp_file_id`, use `temp_file_id` as a key in `setFiles`
|
||||
filesDraft.forEach((fileId) => {
|
||||
filesDraft.fileIds.forEach((fileId) => {
|
||||
const fileData = fileList?.find((f) => f.file_id === fileId);
|
||||
const tempFileData = fileList?.find((f) => f.temp_file_id === fileId);
|
||||
const { fileToRecover, fileIdToRecover } = fileData
|
||||
|
|
@ -63,6 +91,8 @@ export const useAutoSave = ({
|
|||
};
|
||||
|
||||
if (fileToRecover) {
|
||||
fileIdsToKeep.push(fileId);
|
||||
delete pendingPastes[fileId];
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(fileIdToRecover, {
|
||||
|
|
@ -73,15 +103,33 @@ export const useAutoSave = ({
|
|||
});
|
||||
return updatedFiles;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingPaste = pendingPastes[fileId];
|
||||
if (pendingPaste && !activeFileIds.has(fileId) && !hasInFlightUpload(fileId)) {
|
||||
pastesToRecover.push(pendingPaste);
|
||||
delete pendingPastes[fileId];
|
||||
return;
|
||||
}
|
||||
|
||||
fileIdsToKeep.push(fileId);
|
||||
});
|
||||
|
||||
setFilesDraft(id, { fileIds: fileIdsToKeep, pendingPastes });
|
||||
return pastesToRecover;
|
||||
},
|
||||
[fileList, setFiles],
|
||||
);
|
||||
|
||||
const restoreText = useCallback(
|
||||
(id: string) => {
|
||||
setValue('text', getDraft(id) ?? '');
|
||||
(id: string, pendingPastes: PendingTextAttachmentDraft[] = []) => {
|
||||
const draftText = applyPendingPastesToDraft(getDraft(id) ?? '', pendingPastes);
|
||||
|
||||
if (pendingPastes.length > 0) {
|
||||
setDraft({ id, value: draftText });
|
||||
}
|
||||
setValue('text', draftText);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
|
@ -171,50 +219,34 @@ export const useAutoSave = ({
|
|||
// clear attachment files when switching conversation
|
||||
setFiles(new Map());
|
||||
|
||||
/** The key the attachments live under once the pending draft has been moved. A move that
|
||||
* storage refuses leaves them behind, and recovery has to read them where they still are. */
|
||||
let filesDraftId = conversationId;
|
||||
|
||||
try {
|
||||
// Check for transition from PENDING_CONVO to a valid conversationId.
|
||||
// An ask-answer key is excluded: it is a temporary overlay, not the
|
||||
// pending draft's destination — migrating would delete the very draft
|
||||
// the answer-phase swap-back is supposed to restore.
|
||||
if (
|
||||
prevConversationIdRef.current === Constants.PENDING_CONVO &&
|
||||
conversationId !== Constants.PENDING_CONVO &&
|
||||
prevConversationIdRef.current === pendingDraftId &&
|
||||
conversationId !== pendingDraftId &&
|
||||
!isAskAnswerDraftId(conversationId) &&
|
||||
!isNewConversationDraftId(conversationId) &&
|
||||
conversationId.length > 3
|
||||
) {
|
||||
const pendingDraft = localStorage.getItem(
|
||||
`${LocalStorageKeys.TEXT_DRAFT}${Constants.PENDING_CONVO}`,
|
||||
);
|
||||
|
||||
// Clear the pending text draft, if it exists, and save the current draft to the new conversationId;
|
||||
// otherwise, save the current text area value to the new conversationId
|
||||
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${Constants.PENDING_CONVO}`);
|
||||
if (pendingDraft) {
|
||||
localStorage.setItem(`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`, pendingDraft);
|
||||
} else if (textAreaRef?.current?.value) {
|
||||
// Move the pending text draft to the new conversationId, falling back to the current
|
||||
// text area value when there was no pending draft to carry over
|
||||
if (!migrateTextDraft(pendingDraftId, conversationId) && textAreaRef?.current?.value) {
|
||||
setDraft({ id: conversationId, value: textAreaRef.current.value });
|
||||
}
|
||||
const pendingFileDraft = localStorage.getItem(
|
||||
`${LocalStorageKeys.FILES_DRAFT}${Constants.PENDING_CONVO}`,
|
||||
);
|
||||
|
||||
if (pendingFileDraft) {
|
||||
localStorage.setItem(
|
||||
`${LocalStorageKeys.FILES_DRAFT}${conversationId}`,
|
||||
pendingFileDraft,
|
||||
);
|
||||
localStorage.removeItem(`${LocalStorageKeys.FILES_DRAFT}${Constants.PENDING_CONVO}`);
|
||||
const filesDraft = JSON.parse(pendingFileDraft || '[]') as string[];
|
||||
if (filesDraft.length > 0) {
|
||||
restoreFiles(conversationId);
|
||||
}
|
||||
}
|
||||
filesDraftId = migrateFilesDraft(pendingDraftId, conversationId);
|
||||
} else if (currentConversationId != null && currentConversationId) {
|
||||
saveText(currentConversationId);
|
||||
}
|
||||
|
||||
restoreText(conversationId);
|
||||
restoreFiles(conversationId);
|
||||
const pendingPastes = restoreFiles(filesDraftId);
|
||||
restoreText(conversationId, pendingPastes);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
|
@ -224,6 +256,7 @@ export const useAutoSave = ({
|
|||
}, [
|
||||
currentConversationId,
|
||||
conversationId,
|
||||
pendingDraftId,
|
||||
restoreFiles,
|
||||
textAreaRef,
|
||||
restoreText,
|
||||
|
|
@ -232,6 +265,23 @@ export const useAutoSave = ({
|
|||
setFiles,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!saveDrafts ||
|
||||
conversationId == null ||
|
||||
conversationId === '' ||
|
||||
currentConversationId !== conversationId ||
|
||||
fileList == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingPastes = restoreFiles(conversationId);
|
||||
if (pendingPastes.length > 0) {
|
||||
restoreText(conversationId, pendingPastes);
|
||||
}
|
||||
}, [conversationId, currentConversationId, fileList, restoreFiles, restoreText, saveDrafts]);
|
||||
|
||||
useEffect(() => {
|
||||
// This useEffect is responsible for saving or removing the current conversation's file drafts
|
||||
// in localStorage whenever the file attachments change.
|
||||
|
|
@ -247,13 +297,15 @@ export const useAutoSave = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (fileIds.length === 0) {
|
||||
localStorage.removeItem(`${LocalStorageKeys.FILES_DRAFT}${conversationId}`);
|
||||
} else {
|
||||
localStorage.setItem(
|
||||
`${LocalStorageKeys.FILES_DRAFT}${conversationId}`,
|
||||
JSON.stringify(fileIds),
|
||||
);
|
||||
}
|
||||
}, [files, conversationId, saveDrafts, currentConversationId, fileIds]);
|
||||
const existingDraft = getFilesDraft(conversationId);
|
||||
const pendingFileIds = Object.keys(existingDraft.pendingPastes);
|
||||
const draftFileIds = [
|
||||
...fileIds,
|
||||
...pendingFileIds.filter((fileId) => !fileIds.includes(fileId)),
|
||||
];
|
||||
setFilesDraft(conversationId, {
|
||||
fileIds: draftFileIds,
|
||||
pendingPastes: existingDraft.pendingPastes,
|
||||
});
|
||||
}, [conversationId, saveDrafts, currentConversationId, fileIds]);
|
||||
};
|
||||
|
|
|
|||
902
client/src/hooks/Input/useTextarea.spec.tsx
Normal file
902
client/src/hooks/Input/useTextarea.spec.tsx
Normal file
|
|
@ -0,0 +1,902 @@
|
|||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { Constants, EToolResources } from 'librechat-data-provider';
|
||||
import type { UploadLifecycleCallbacks } from '~/hooks/Files/useFileHandling';
|
||||
import type { PasteAsFileContext } from '~/utils/files';
|
||||
import {
|
||||
getDraft,
|
||||
getFilesDraft,
|
||||
getNewConversationDraftId,
|
||||
getPendingDraftId,
|
||||
renewNewConversationDraftToken,
|
||||
} from '~/utils/drafts';
|
||||
|
||||
const mockForceResize = jest.fn();
|
||||
const mockInsertTextAtCursor = jest.fn();
|
||||
const mockResolvePastedTextFile = jest.fn();
|
||||
const mockRouteFiles = jest.fn();
|
||||
const mockGetUploadOptions = jest.fn(() => [EToolResources.context]);
|
||||
const mockSetFilesLoading = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
const mockOpenModal = jest.fn();
|
||||
const mockLocalize = jest.fn((key: string) => key);
|
||||
const mockSetActivePrompt = jest.fn();
|
||||
|
||||
let useTextarea: typeof import('./useTextarea').default;
|
||||
let mockIndex = 0;
|
||||
let mockIsSubmitting = false;
|
||||
let mockIsUploadConfigPending = false;
|
||||
let mockConversation: { endpoint: string; conversationId?: string } = {
|
||||
endpoint: 'openAI',
|
||||
conversationId: 'convo-1',
|
||||
};
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
...jest.requireActual('~/utils/drafts'),
|
||||
forceResize: mockForceResize,
|
||||
insertTextAtCursor: mockInsertTextAtCursor,
|
||||
resolvePastedTextFile: mockResolvePastedTextFile,
|
||||
getEntityName: jest.fn(() => ''),
|
||||
getEntity: jest.fn(() => ({ entity: undefined, isAgent: false, isAssistant: false })),
|
||||
checkIfScrollable: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('recoil', () => ({
|
||||
useRecoilValue: jest.fn(() => true),
|
||||
useRecoilState: jest.fn(() => [undefined, mockSetActivePrompt]),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
useToastContext: jest.fn(() => ({ showToast: mockShowToast })),
|
||||
}));
|
||||
|
||||
jest.mock('~/store', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
enterToSend: { key: 'enterToSend' },
|
||||
saveDrafts: { key: 'saveDrafts' },
|
||||
pasteLongTextAsFile: { key: 'pasteLongTextAsFile' },
|
||||
activePromptByIndex: jest.fn(() => ({ key: 'activePrompt' })),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers/AssistantsMapContext', () => ({
|
||||
useAssistantsMapContext: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Messages/useLatestMessage', () => ({
|
||||
useLatestMessageMeta: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Input/useComposerBindings', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ submitOverride: undefined, yieldedChords: undefined })),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Files/useFileUploadRouter', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => mockRouteFiles),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers/AgentsMapContext', () => ({
|
||||
useAgentsMapContext: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Conversations/useGetSender', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => jest.fn(() => 'Assistant')),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Files/useUploadOptions', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
getOptions: mockGetUploadOptions,
|
||||
uploadsDisabled: false,
|
||||
isConfigPending: mockIsUploadConfigPending,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useInteractionHealthCheck: jest.fn(() => jest.fn(async () => true)),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers/ChatContext', () => ({
|
||||
useChatContext: jest.fn(() => ({
|
||||
index: mockIndex,
|
||||
conversation: mockConversation,
|
||||
isSubmitting: mockIsSubmitting,
|
||||
files: new Map(),
|
||||
setFilesLoading: mockSetFilesLoading,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useUploadModalContext: jest.fn(() => ({ openModal: mockOpenModal })),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils/shortcuts', () => ({
|
||||
resolveComposerKeyDown: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/common', () => ({ globalAudioId: 'global-audio' }));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn(() => mockLocalize),
|
||||
}));
|
||||
|
||||
const pastedText = 'a'.repeat(2501);
|
||||
|
||||
beforeAll(async () => {
|
||||
useTextarea = (await import('./useTextarea')).default;
|
||||
});
|
||||
|
||||
const createPasteEvent = (files: File[] = []) => ({
|
||||
clipboardData: {
|
||||
files,
|
||||
getData: jest.fn(() => pastedText),
|
||||
},
|
||||
preventDefault: jest.fn(),
|
||||
});
|
||||
|
||||
const renderTextareaHook = (initialAnswerModeActive = false) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
const submitButton = document.createElement('button');
|
||||
const setIsScrollable = jest.fn();
|
||||
let answerModeActive = initialAnswerModeActive;
|
||||
const hook = renderHook(() =>
|
||||
useTextarea({
|
||||
textAreaRef: { current: textArea },
|
||||
submitButtonRef: { current: submitButton },
|
||||
setIsScrollable,
|
||||
answerModeActive,
|
||||
}),
|
||||
);
|
||||
const rerender = (nextAnswerModeActive = answerModeActive) => {
|
||||
answerModeActive = nextAnswerModeActive;
|
||||
hook.rerender();
|
||||
};
|
||||
|
||||
return { ...hook, rerender, textArea };
|
||||
};
|
||||
|
||||
describe('useTextarea long-paste fallback', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockIndex = 0;
|
||||
mockIsSubmitting = false;
|
||||
mockIsUploadConfigPending = false;
|
||||
mockConversation = { endpoint: 'openAI', conversationId: 'convo-1' };
|
||||
mockGetUploadOptions.mockReturnValue([EToolResources.context]);
|
||||
mockResolvePastedTextFile.mockImplementation((text: string) => ({
|
||||
file: new File([text], 'pasted-text.txt', { type: 'text/plain' }),
|
||||
toolResource: EToolResources.context,
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps long pasted text inline while the composer is the answer box', () => {
|
||||
const { result } = renderTextareaHook(true);
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(mockResolvePastedTextFile).not.toHaveBeenCalled();
|
||||
expect(mockRouteFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the paste when attachment validation rejects the file', async () => {
|
||||
mockRouteFiles.mockResolvedValueOnce(false);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
textArea.value = 'before selected after';
|
||||
textArea.setSelectionRange(7, 15);
|
||||
mockInsertTextAtCursor.mockImplementationOnce((element: HTMLTextAreaElement, text: string) => {
|
||||
element.setRangeText(text, element.selectionStart, element.selectionEnd, 'end');
|
||||
});
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(mockInsertTextAtCursor).toHaveBeenCalledTimes(1));
|
||||
expect(mockInsertTextAtCursor).toHaveBeenCalledWith(textArea, pastedText);
|
||||
expect(textArea.value).toBe(`before ${pastedText} after`);
|
||||
expect(mockForceResize).toHaveBeenCalledWith(textArea);
|
||||
});
|
||||
|
||||
it('does not restore the paste when the attachment is accepted', async () => {
|
||||
mockRouteFiles.mockResolvedValueOnce(true);
|
||||
const { result } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() =>
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_ui_file_attached_as_text',
|
||||
status: 'info',
|
||||
}),
|
||||
);
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
expect(mockForceResize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces selected draft text when the attachment is accepted', async () => {
|
||||
mockRouteFiles.mockResolvedValueOnce(true);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
const inputListener = jest.fn();
|
||||
textArea.value = 'before selected after';
|
||||
textArea.setSelectionRange(7, 15);
|
||||
textArea.addEventListener('input', inputListener);
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(textArea.value).toBe('before after');
|
||||
expect(textArea.selectionStart).toBe(7);
|
||||
expect(textArea.selectionEnd).toBe(7);
|
||||
expect(inputListener).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(mockRouteFiles).toHaveBeenCalledTimes(1));
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the paste after an upload failure when the composer is unchanged', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('file-1');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
textArea.value = 'before selected after';
|
||||
textArea.setSelectionRange(7, 15);
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle).toBeDefined());
|
||||
expect(textArea.value).toBe('before after');
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
|
||||
act(() => uploadLifecycle?.onError?.('file-1'));
|
||||
|
||||
expect(mockInsertTextAtCursor).toHaveBeenCalledWith(textArea, pastedText);
|
||||
expect(mockForceResize).toHaveBeenCalledWith(textArea);
|
||||
expect(getFilesDraft('convo-1')).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
});
|
||||
|
||||
it('skips upload-failure recovery when the conversation changed', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('file-1');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, rerender } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle).toBeDefined());
|
||||
mockConversation = { endpoint: 'openAI', conversationId: 'convo-2' };
|
||||
rerender();
|
||||
|
||||
act(() => uploadLifecycle?.onError?.('file-1'));
|
||||
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
expect(mockForceResize).not.toHaveBeenCalled();
|
||||
expect(getFilesDraft('convo-1').pendingPastes['file-1']?.text).toBe(pastedText);
|
||||
});
|
||||
|
||||
it('skips upload-failure recovery when answer mode becomes active', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, rerender } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle).toBeDefined());
|
||||
rerender(true);
|
||||
|
||||
act(() => uploadLifecycle?.onError?.('file-1'));
|
||||
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
expect(mockForceResize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips upload-failure recovery when the composer content changed', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('file-1');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
textArea.value = 'draft at paste time';
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle).toBeDefined());
|
||||
textArea.value = 'draft after more typing';
|
||||
|
||||
act(() => uploadLifecycle?.onError?.('file-1'));
|
||||
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
expect(mockForceResize).not.toHaveBeenCalled();
|
||||
expect(getFilesDraft('convo-1').pendingPastes['file-1']?.text).toBe(pastedText);
|
||||
});
|
||||
|
||||
it('forwards upload recovery through the assistants route', async () => {
|
||||
mockConversation = { endpoint: 'assistants' };
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(
|
||||
_files: File[],
|
||||
_toolResource: EToolResources | undefined,
|
||||
lifecycle?: UploadLifecycleCallbacks,
|
||||
) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle).toBeDefined());
|
||||
expect(mockRouteFiles).toHaveBeenCalledWith(expect.any(Array), undefined, uploadLifecycle);
|
||||
|
||||
act(() => uploadLifecycle?.onError?.('file-1'));
|
||||
|
||||
expect(mockInsertTextAtCursor).toHaveBeenCalledWith(textArea, pastedText);
|
||||
expect(mockForceResize).toHaveBeenCalledWith(textArea);
|
||||
});
|
||||
|
||||
it('does not restore a failed paste into a second unsaved draft', async () => {
|
||||
mockConversation = { endpoint: 'openAI', conversationId: Constants.NEW_CONVO as string };
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('first-draft-file');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, rerender } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle).toBeDefined());
|
||||
renewNewConversationDraftToken();
|
||||
mockConversation = { endpoint: 'openAI', conversationId: Constants.NEW_CONVO as string };
|
||||
rerender();
|
||||
|
||||
act(() => uploadLifecycle?.onError?.('first-draft-file'));
|
||||
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
expect(mockForceResize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still restores a failed paste when another pane starts a new conversation', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('first-draft-file');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle).toBeDefined());
|
||||
renewNewConversationDraftToken(1);
|
||||
|
||||
act(() => uploadLifecycle?.onError?.('first-draft-file'));
|
||||
|
||||
expect(mockInsertTextAtCursor).toHaveBeenCalledWith(textArea, pastedText);
|
||||
expect(mockForceResize).toHaveBeenCalledWith(textArea);
|
||||
expect(getFilesDraft('convo-1')).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
});
|
||||
|
||||
it('stores a pending paste under the submitting pane draft key', async () => {
|
||||
mockIndex = 1;
|
||||
mockIsSubmitting = true;
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('pane-1-file');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getFilesDraft(getPendingDraftId(1)).pendingPastes['pane-1-file']?.text).toBe(
|
||||
pastedText,
|
||||
),
|
||||
);
|
||||
expect(getFilesDraft(Constants.PENDING_CONVO)).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
expect(uploadLifecycle).toBeDefined();
|
||||
});
|
||||
|
||||
it('stores a pending paste under the idle unsaved pane draft key', async () => {
|
||||
mockIndex = 1;
|
||||
mockConversation = { endpoint: 'openAI', conversationId: Constants.NEW_CONVO as string };
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('pane-1-new-file');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
getFilesDraft(getNewConversationDraftId(1)).pendingPastes['pane-1-new-file']?.text,
|
||||
).toBe(pastedText),
|
||||
);
|
||||
expect(getFilesDraft(Constants.NEW_CONVO)).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
expect(uploadLifecycle).toBeDefined();
|
||||
});
|
||||
|
||||
it('persists paste recovery before the upload route resolves', async () => {
|
||||
let finish!: (accepted: boolean) => void;
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
return new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
);
|
||||
const { result } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
expect(uploadLifecycle?.fileId).toEqual(expect.any(String));
|
||||
expect(getFilesDraft('convo-1').pendingPastes[uploadLifecycle?.fileId ?? '']?.text).toBe(
|
||||
pastedText,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
finish(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not restore a delayed rejection after the conversation changes', async () => {
|
||||
let finish!: (accepted: boolean) => void;
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
return new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
);
|
||||
const { result, rerender } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
const fileId = uploadLifecycle?.fileId ?? '';
|
||||
expect(getFilesDraft('convo-1').pendingPastes[fileId]?.text).toBe(pastedText);
|
||||
mockConversation = { endpoint: 'openAI', conversationId: 'convo-2' };
|
||||
rerender();
|
||||
|
||||
await act(async () => {
|
||||
finish(false);
|
||||
});
|
||||
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
expect(mockForceResize).not.toHaveBeenCalled();
|
||||
expect(getFilesDraft('convo-1').pendingPastes[fileId]?.text).toBe(pastedText);
|
||||
});
|
||||
|
||||
it('does not restore a delayed rejection after the composer changes', async () => {
|
||||
let finish!: (accepted: boolean) => void;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
textArea.value = 'draft at paste time';
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
textArea.value = 'draft after more typing';
|
||||
|
||||
await act(async () => {
|
||||
finish(false);
|
||||
});
|
||||
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
expect(mockForceResize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists the replaced selection so reload recovery can drop stale draft text', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('replaced-file');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
textArea.value = 'before selected after';
|
||||
textArea.setSelectionRange(7, 15);
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getFilesDraft('convo-1').pendingPastes['replaced-file']).toEqual({
|
||||
text: pastedText,
|
||||
selectionStart: 7,
|
||||
selectionEnd: 15,
|
||||
replacedText: 'selected',
|
||||
sequence: 1,
|
||||
replacedApplied: true,
|
||||
anchorBefore: 'before ',
|
||||
anchorAfter: ' after',
|
||||
}),
|
||||
);
|
||||
expect(getDraft('convo-1')).toBe('before after');
|
||||
expect(textArea.value).toBe('before after');
|
||||
expect(uploadLifecycle).toBeDefined();
|
||||
});
|
||||
|
||||
it('persists a one-character composer snapshot while the paste upload is pending', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('one-char-file');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
textArea.value = 'x';
|
||||
textArea.setSelectionRange(1, 1);
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle).toBeDefined());
|
||||
expect(textArea.value).toBe('x');
|
||||
expect(getDraft('convo-1')).toBe('x');
|
||||
});
|
||||
|
||||
it('still starts the paste upload when draft persistence throws', async () => {
|
||||
const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota exceeded');
|
||||
});
|
||||
mockRouteFiles.mockResolvedValueOnce(true);
|
||||
const { result } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
expect(() =>
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(mockRouteFiles).toHaveBeenCalledTimes(1));
|
||||
setItem.mockRestore();
|
||||
});
|
||||
|
||||
it('removes durable pasted text after a successful upload', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
lifecycle?.onStart?.('successful-file');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getFilesDraft('convo-1').pendingPastes['successful-file']?.text).toBe(pastedText),
|
||||
);
|
||||
act(() => uploadLifecycle?.onSuccess?.('successful-file'));
|
||||
|
||||
expect(getFilesDraft('convo-1')).toEqual({
|
||||
fileIds: ['successful-file'],
|
||||
pendingPastes: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat a successful upload as failed when draft storage reads throw', async () => {
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
);
|
||||
const { result } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(uploadLifecycle?.fileId).toEqual(expect.any(String)));
|
||||
const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked');
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
act(() => {
|
||||
uploadLifecycle?.onSuccess?.(uploadLifecycle?.fileId ?? 'missing-file');
|
||||
}),
|
||||
).not.toThrow();
|
||||
getItem.mockRestore();
|
||||
});
|
||||
|
||||
it('restores the paste when attachment routing rejects unexpectedly', async () => {
|
||||
const error = new Error('upload failed');
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mockRouteFiles.mockRejectedValueOnce(error);
|
||||
const { result, textArea } = renderTextareaHook();
|
||||
const event = createPasteEvent();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockInsertTextAtCursor).toHaveBeenCalledTimes(1));
|
||||
expect(mockInsertTextAtCursor).toHaveBeenCalledWith(textArea, pastedText);
|
||||
expect(mockForceResize).toHaveBeenCalledWith(textArea);
|
||||
expect(consoleError).toHaveBeenCalledWith('clipboard file routing error', error);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('clears the loading state when clipboard file routing rejects unexpectedly', async () => {
|
||||
const error = new Error('upload failed');
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mockRouteFiles.mockRejectedValueOnce(error);
|
||||
const { result } = renderTextareaHook();
|
||||
const event = createPasteEvent([new File(['file'], 'notes.txt', { type: 'text/plain' })]);
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(event as unknown as React.ClipboardEvent<HTMLTextAreaElement>),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockSetFilesLoading).toHaveBeenLastCalledWith(false));
|
||||
expect(consoleError).toHaveBeenCalledWith('clipboard file routing error', error);
|
||||
expect(mockInsertTextAtCursor).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('routes a paste to context while the file config is still pending', async () => {
|
||||
mockIsUploadConfigPending = true;
|
||||
mockGetUploadOptions.mockReturnValue([]);
|
||||
mockRouteFiles.mockResolvedValueOnce(true);
|
||||
const { result } = renderTextareaHook();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(
|
||||
createPasteEvent() as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockRouteFiles).toHaveBeenCalledTimes(1));
|
||||
expect(mockRouteFiles.mock.calls[0][1]).toBe(EToolResources.context);
|
||||
expect(mockGetUploadOptions).not.toHaveBeenCalled();
|
||||
expect(mockShowToast).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'com_error_files_unsupported' }),
|
||||
);
|
||||
expect(mockOpenModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still rejects a paste once the loaded config offers nothing', async () => {
|
||||
mockGetUploadOptions.mockReturnValue([]);
|
||||
const { result } = renderTextareaHook();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(
|
||||
createPasteEvent() as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_error_files_unsupported',
|
||||
status: 'error',
|
||||
}),
|
||||
);
|
||||
expect(mockRouteFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('numbers a second paste made while the first is still queued', async () => {
|
||||
mockResolvePastedTextFile.mockImplementation((text: string, ctx: PasteAsFileContext) => {
|
||||
const name = ctx.attachedFilenames.has('pasted-text.txt')
|
||||
? 'pasted-text-2.txt'
|
||||
: 'pasted-text.txt';
|
||||
return {
|
||||
file: new File([text], name, { type: 'text/plain' }),
|
||||
toolResource: EToolResources.context,
|
||||
};
|
||||
});
|
||||
let finishFirst!: (accepted: boolean) => void;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishFirst = resolve;
|
||||
}),
|
||||
);
|
||||
mockRouteFiles.mockResolvedValueOnce(true);
|
||||
const { result } = renderTextareaHook();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(
|
||||
createPasteEvent() as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
|
||||
),
|
||||
);
|
||||
act(() =>
|
||||
result.current.handlePaste(
|
||||
createPasteEvent() as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockRouteFiles.mock.calls[0][0][0].name).toBe('pasted-text.txt');
|
||||
expect(mockRouteFiles.mock.calls[1][0][0].name).toBe('pasted-text-2.txt');
|
||||
|
||||
await act(async () => {
|
||||
finishFirst(true);
|
||||
});
|
||||
|
||||
mockRouteFiles.mockResolvedValueOnce(true);
|
||||
act(() =>
|
||||
result.current.handlePaste(
|
||||
createPasteEvent() as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockRouteFiles.mock.calls[2][0][0].name).toBe('pasted-text.txt');
|
||||
});
|
||||
|
||||
it('abandons a queued paste upload once the composer moved to another conversation', async () => {
|
||||
let finish!: (accepted: boolean) => void;
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
return new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
);
|
||||
const { result, rerender } = renderTextareaHook();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(
|
||||
createPasteEvent() as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(uploadLifecycle?.shouldCommit?.()).toBe(true);
|
||||
|
||||
mockConversation = { endpoint: 'openAI', conversationId: 'convo-2' };
|
||||
rerender();
|
||||
|
||||
expect(uploadLifecycle?.shouldCommit?.()).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
finish(false);
|
||||
});
|
||||
|
||||
expect(getFilesDraft('convo-1').pendingPastes[uploadLifecycle?.fileId ?? '']?.text).toBe(
|
||||
pastedText,
|
||||
);
|
||||
});
|
||||
|
||||
it('abandons a queued paste upload once the pane started another unsaved chat', async () => {
|
||||
mockConversation = { endpoint: 'openAI', conversationId: Constants.NEW_CONVO as string };
|
||||
let finish!: (accepted: boolean) => void;
|
||||
let uploadLifecycle: UploadLifecycleCallbacks | undefined;
|
||||
mockRouteFiles.mockImplementationOnce(
|
||||
(_files: File[], _toolResource: EToolResources, lifecycle?: UploadLifecycleCallbacks) => {
|
||||
uploadLifecycle = lifecycle;
|
||||
return new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
);
|
||||
const { result } = renderTextareaHook();
|
||||
|
||||
act(() =>
|
||||
result.current.handlePaste(
|
||||
createPasteEvent() as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(uploadLifecycle?.shouldCommit?.()).toBe(true);
|
||||
|
||||
renewNewConversationDraftToken(0);
|
||||
|
||||
expect(uploadLifecycle?.shouldCommit?.()).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,21 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useRecoilValue, useRecoilState } from 'recoil';
|
||||
import { EToolResources, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { TEndpointOption } from 'librechat-data-provider';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { UploadLifecycleCallbacks } from '~/hooks/Files/useFileHandling';
|
||||
import {
|
||||
forceResize,
|
||||
insertTextAtCursor,
|
||||
resolvePastedTextFile,
|
||||
getNewConversationDraftToken,
|
||||
getComposerDraftId,
|
||||
setPendingTextAttachmentDraft,
|
||||
removePendingTextAttachmentDraft,
|
||||
setDraft,
|
||||
getEntityName,
|
||||
getEntity,
|
||||
checkIfScrollable,
|
||||
|
|
@ -37,6 +45,7 @@ export default function useTextarea({
|
|||
placeholder,
|
||||
allowSubmitWhileGenerating = false,
|
||||
onDuringRunModifier,
|
||||
answerModeActive = false,
|
||||
}: {
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
|
|
@ -48,21 +57,38 @@ export default function useTextarea({
|
|||
/** During-run modifier chords: ⌘/Ctrl+Enter = the non-default action,
|
||||
* ⌥/Alt+Enter = interrupt & send. Enter itself submits the default. */
|
||||
onDuringRunModifier?: (kind: 'other' | 'interrupt' | 'preempt') => void;
|
||||
/** Keeps pasted text inline while the composer is answering a paused question. */
|
||||
answerModeActive?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const getSender = useGetSender();
|
||||
const isComposing = useRef(false);
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const { showToast } = useToastContext();
|
||||
const { getOptions: getUploadOptions, uploadsDisabled } = useUploadOptions();
|
||||
const {
|
||||
getOptions: getUploadOptions,
|
||||
uploadsDisabled,
|
||||
isConfigPending: isUploadConfigPending,
|
||||
} = useUploadOptions();
|
||||
const routeFiles = useFileUploadRouter();
|
||||
const { openModal } = useUploadModalContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const checkHealth = useInteractionHealthCheck();
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const saveDrafts = useRecoilValue(store.saveDrafts);
|
||||
const pasteLongTextAsFile = useRecoilValue(store.pasteLongTextAsFile);
|
||||
const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings();
|
||||
|
||||
const { index, conversation, isSubmitting, setFilesLoading } = useChatContext();
|
||||
const { index, conversation, isSubmitting, files, setFilesLoading } = useChatContext();
|
||||
const conversationIdRef = useRef(conversation?.conversationId);
|
||||
conversationIdRef.current = conversation?.conversationId;
|
||||
const isSubmittingRef = useRef(isSubmitting);
|
||||
isSubmittingRef.current = isSubmitting;
|
||||
const answerModeActiveRef = useRef(answerModeActive);
|
||||
answerModeActiveRef.current = answerModeActive;
|
||||
/** Names handed to pastes that are still waiting on the shared upload queue, so a second
|
||||
* paste is numbered against them instead of colliding once the queue drains. */
|
||||
const reservedPasteFilenames = useRef<Set<string>>(new Set());
|
||||
const latestMessage = useLatestMessageMeta(index);
|
||||
const [activePrompt, setActivePrompt] = useRecoilState(store.activePromptByIndex(index));
|
||||
|
||||
|
|
@ -236,6 +262,75 @@ export default function useTextarea({
|
|||
isComposing.current = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends clipboard-derived files to their upload destination, prompting when several are
|
||||
* viable. `preferred` skips that prompt when the caller already knows the intent.
|
||||
*/
|
||||
const routeClipboardFiles = useCallback(
|
||||
async (
|
||||
clipboardFiles: File[],
|
||||
preferred?: EToolResources,
|
||||
uploadLifecycle?: UploadLifecycleCallbacks,
|
||||
): Promise<boolean> => {
|
||||
setFilesLoading(true);
|
||||
|
||||
const upload = async (destination?: EToolResources): Promise<boolean> => {
|
||||
/** Held until the upload is accepted so a rejected file (a duplicate, an oversized one)
|
||||
* reports only its own error instead of pairing it with a success message. */
|
||||
const accepted = await routeFiles(clipboardFiles, destination, uploadLifecycle);
|
||||
if (accepted && destination === EToolResources.context) {
|
||||
showToast({ message: localize('com_ui_file_attached_as_text'), status: 'info' });
|
||||
}
|
||||
return accepted;
|
||||
};
|
||||
|
||||
try {
|
||||
/** Assistants use their own upload path; bypass option resolution like drag-and-drop does */
|
||||
if (isAssistantsEndpoint(conversation?.endpoint)) {
|
||||
return await upload();
|
||||
}
|
||||
|
||||
/** Resolving options reads the file config, so until that lands the list is empty for
|
||||
* reasons that have nothing to do with this file. A caller that already knows where the
|
||||
* file belongs hands it to the upload instead, which waits for the same config and
|
||||
* validates against it, rather than rejecting or re-routing on a stale answer here. */
|
||||
if (preferred != null && isUploadConfigPending) {
|
||||
return await upload(preferred);
|
||||
}
|
||||
|
||||
const options = getUploadOptions(clipboardFiles);
|
||||
if (options.length === 0) {
|
||||
showToast({ message: localize('com_error_files_unsupported'), status: 'error' });
|
||||
setFilesLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const usePreferred = preferred != null && options.includes(preferred);
|
||||
if (!usePreferred && options.length > 1) {
|
||||
setFilesLoading(false);
|
||||
openModal(clipboardFiles);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await upload(usePreferred ? preferred : options[0]);
|
||||
} catch (error) {
|
||||
console.error('clipboard file routing error', error);
|
||||
setFilesLoading(false);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
localize,
|
||||
showToast,
|
||||
openModal,
|
||||
routeFiles,
|
||||
conversation,
|
||||
setFilesLoading,
|
||||
getUploadOptions,
|
||||
isUploadConfigPending,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const textArea = textAreaRef.current;
|
||||
|
|
@ -249,7 +344,6 @@ export default function useTextarea({
|
|||
}
|
||||
|
||||
if (clipboardData.files.length > 0) {
|
||||
setFilesLoading(true);
|
||||
const timestampedFiles: File[] = [];
|
||||
for (const file of clipboardData.files) {
|
||||
const newFile = new File([file], `clipboard_${+new Date()}_${file.name}`, {
|
||||
|
|
@ -260,43 +354,182 @@ export default function useTextarea({
|
|||
|
||||
if (uploadsDisabled) {
|
||||
showToast({ message: localize('com_ui_attach_error_disabled'), status: 'error' });
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/** Assistants use their own upload path; bypass option resolution like drag-and-drop does */
|
||||
if (isAssistantsEndpoint(conversation?.endpoint)) {
|
||||
routeFiles(timestampedFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = getUploadOptions(timestampedFiles);
|
||||
if (options.length === 0) {
|
||||
showToast({ message: localize('com_error_files_unsupported'), status: 'error' });
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
}
|
||||
if (options.length === 1) {
|
||||
routeFiles(timestampedFiles, options[0]);
|
||||
if (options[0] === EToolResources.context) {
|
||||
showToast({ message: localize('com_ui_file_attached_as_text'), status: 'info' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setFilesLoading(false);
|
||||
openModal(timestampedFiles);
|
||||
void routeClipboardFiles(timestampedFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
if (answerModeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachedFilenames = new Set(reservedPasteFilenames.current);
|
||||
for (const attached of files.values()) {
|
||||
attachedFilenames.add(attached.file?.name ?? attached.filename ?? '');
|
||||
}
|
||||
|
||||
const pastedText = clipboardData.getData('text/plain');
|
||||
const attachment = resolvePastedTextFile(pastedText, {
|
||||
enabled: pasteLongTextAsFile,
|
||||
uploadsDisabled,
|
||||
isAssistants: isAssistantsEndpoint(conversation?.endpoint),
|
||||
attachedFilenames,
|
||||
configPending: isUploadConfigPending,
|
||||
getOptions: getUploadOptions,
|
||||
});
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const pastedFilename = attachment.file.name;
|
||||
reservedPasteFilenames.current.add(pastedFilename);
|
||||
const conversationId = conversation?.conversationId;
|
||||
const draftId = getComposerDraftId(index, conversationId, isSubmitting);
|
||||
const draftToken = getNewConversationDraftToken(index);
|
||||
const selectionStart = textArea.selectionStart;
|
||||
const selectionEnd = textArea.selectionEnd;
|
||||
const replacedText =
|
||||
selectionStart === selectionEnd ? '' : textArea.value.slice(selectionStart, selectionEnd);
|
||||
if (selectionStart !== selectionEnd) {
|
||||
textArea.setRangeText('', selectionStart, selectionEnd, 'end');
|
||||
textArea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
forceResize(textArea);
|
||||
}
|
||||
const composerValue = textArea.value;
|
||||
const pendingFileId = v4();
|
||||
if (saveDrafts) {
|
||||
try {
|
||||
setDraft({ id: draftId, value: composerValue, persistExact: true });
|
||||
setPendingTextAttachmentDraft({
|
||||
id: draftId,
|
||||
fileId: pendingFileId,
|
||||
text: pastedText,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
replacedText,
|
||||
replacedApplied: true,
|
||||
anchorBefore: composerValue.slice(0, selectionStart),
|
||||
anchorAfter: composerValue.slice(selectionStart),
|
||||
});
|
||||
} catch {
|
||||
// Persistence must not prevent the generated attachment from uploading.
|
||||
}
|
||||
}
|
||||
const restorePaste = (target: HTMLTextAreaElement) => {
|
||||
target.setSelectionRange(selectionStart, selectionStart);
|
||||
insertTextAtCursor(target, pastedText);
|
||||
forceResize(target);
|
||||
};
|
||||
/** The composer that owns this paste: same conversation, same unsaved-chat identity. */
|
||||
const isOriginatingComposer = (): boolean =>
|
||||
conversationIdRef.current === conversationId &&
|
||||
getNewConversationDraftToken(index) === draftToken;
|
||||
const restorePasteAfterUploadFailure = (): boolean => {
|
||||
const currentTextArea = textAreaRef.current;
|
||||
if (
|
||||
!currentTextArea ||
|
||||
answerModeActiveRef.current ||
|
||||
!isOriginatingComposer() ||
|
||||
currentTextArea.value !== composerValue
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
restorePaste(currentTextArea);
|
||||
if (saveDrafts) {
|
||||
setDraft({
|
||||
id: getComposerDraftId(index, conversationIdRef.current, isSubmittingRef.current),
|
||||
value: currentTextArea.value,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const clearPendingPasteDraft = (fileId: string, removeFile = false) => {
|
||||
if (!saveDrafts) {
|
||||
return;
|
||||
}
|
||||
removePendingTextAttachmentDraft({ id: draftId, fileId, removeFile });
|
||||
const currentDraftId = getComposerDraftId(
|
||||
index,
|
||||
conversationIdRef.current,
|
||||
isSubmittingRef.current,
|
||||
);
|
||||
if (currentDraftId !== draftId) {
|
||||
removePendingTextAttachmentDraft({ id: currentDraftId, fileId, removeFile });
|
||||
}
|
||||
};
|
||||
const uploadLifecycle: UploadLifecycleCallbacks = {
|
||||
fileId: pendingFileId,
|
||||
shouldCommit: isOriginatingComposer,
|
||||
onStart: (fileId) => {
|
||||
if (!saveDrafts || fileId === pendingFileId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
removePendingTextAttachmentDraft({
|
||||
id: draftId,
|
||||
fileId: pendingFileId,
|
||||
removeFile: true,
|
||||
});
|
||||
setPendingTextAttachmentDraft({
|
||||
id: draftId,
|
||||
fileId,
|
||||
text: pastedText,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
replacedText,
|
||||
replacedApplied: true,
|
||||
anchorBefore: composerValue.slice(0, selectionStart),
|
||||
anchorAfter: composerValue.slice(selectionStart),
|
||||
});
|
||||
} catch {
|
||||
// Keep the upload going if durable recovery cannot be written.
|
||||
}
|
||||
},
|
||||
onSuccess: (fileId) => {
|
||||
clearPendingPasteDraft(fileId);
|
||||
},
|
||||
onError: (fileId) => {
|
||||
const restored = restorePasteAfterUploadFailure();
|
||||
if (restored || getNewConversationDraftToken(index) !== draftToken) {
|
||||
clearPendingPasteDraft(fileId, true);
|
||||
}
|
||||
},
|
||||
onAbort: (fileId) => {
|
||||
clearPendingPasteDraft(fileId, true);
|
||||
},
|
||||
};
|
||||
void routeClipboardFiles([attachment.file], attachment.toolResource, uploadLifecycle).then(
|
||||
(accepted) => {
|
||||
/** The name is either attached now or was never taken, so stop reserving it. */
|
||||
reservedPasteFilenames.current.delete(pastedFilename);
|
||||
if (!accepted) {
|
||||
const restored = restorePasteAfterUploadFailure();
|
||||
if (restored || getNewConversationDraftToken(index) !== draftToken) {
|
||||
clearPendingPasteDraft(pendingFileId, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
files,
|
||||
localize,
|
||||
showToast,
|
||||
openModal,
|
||||
routeFiles,
|
||||
conversation,
|
||||
index,
|
||||
textAreaRef,
|
||||
uploadsDisabled,
|
||||
setFilesLoading,
|
||||
getUploadOptions,
|
||||
pasteLongTextAsFile,
|
||||
routeClipboardFiles,
|
||||
isUploadConfigPending,
|
||||
answerModeActive,
|
||||
isSubmitting,
|
||||
saveDrafts,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
getExistingConversationAbortMessages,
|
||||
isInitialNewConversationSubmission,
|
||||
mergeRegenerateFinalMessages,
|
||||
startedAsNewConversation,
|
||||
} from '~/hooks/SSE/useEventHandlers';
|
||||
|
||||
describe('buildCreatedInitialResponse', () => {
|
||||
|
|
@ -80,6 +81,62 @@ describe('isInitialNewConversationSubmission', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('startedAsNewConversation', () => {
|
||||
const rootUserMessage = {
|
||||
messageId: 'user-1',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
} as TMessage;
|
||||
|
||||
it('treats an unsaved conversation as a new chat', () => {
|
||||
for (const conversationId of [undefined, Constants.NEW_CONVO, Constants.PENDING_CONVO]) {
|
||||
expect(
|
||||
startedAsNewConversation({
|
||||
conversation: { conversationId },
|
||||
userMessage: rootUserMessage,
|
||||
} as EventSubmission),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a first turn without a saved id as a new chat', () => {
|
||||
expect(
|
||||
startedAsNewConversation({
|
||||
conversation: {},
|
||||
userMessage: rootUserMessage,
|
||||
} as EventSubmission),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat a regenerated first reply of a saved conversation as a new chat', () => {
|
||||
expect(
|
||||
startedAsNewConversation({
|
||||
conversation: { conversationId: 'conversation-1' },
|
||||
userMessage: rootUserMessage,
|
||||
isRegenerate: true,
|
||||
} as EventSubmission),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a resubmitted first message of a saved conversation as a new chat', () => {
|
||||
expect(
|
||||
startedAsNewConversation({
|
||||
conversation: { conversationId: 'conversation-1' },
|
||||
userMessage: rootUserMessage,
|
||||
isEdited: true,
|
||||
} as EventSubmission),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a follow-up turn of a saved conversation as a new chat', () => {
|
||||
expect(
|
||||
startedAsNewConversation({
|
||||
conversation: { conversationId: 'conversation-1' },
|
||||
userMessage: { messageId: 'user-2', parentMessageId: 'assistant-1' } as TMessage,
|
||||
} as EventSubmission),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeRegenerateFinalMessages', () => {
|
||||
const userMessage = (messageId: string, parentMessageId: string = Constants.NO_PARENT) =>
|
||||
({
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type { ConversationCursorData } from '~/utils';
|
|||
import {
|
||||
logger,
|
||||
setDraft,
|
||||
getConversationDraftId,
|
||||
scrollToEnd,
|
||||
hasRealTitle,
|
||||
setDocumentTitle,
|
||||
|
|
@ -101,6 +102,36 @@ export const isInitialNewConversationSubmission = ({
|
|||
}: Pick<EventSubmission, 'userMessage'>): boolean =>
|
||||
userMessage?.parentMessageId === Constants.NO_PARENT;
|
||||
|
||||
/**
|
||||
* Whether the run was sent from the unsaved-chat composer, which is the only case where
|
||||
* finishing it may drop the pane's new-chat draft. Regenerating or resubmitting the first turn
|
||||
* of a saved conversation keeps the root parent id, so the root parent alone cannot decide it.
|
||||
*/
|
||||
export const startedAsNewConversation = ({
|
||||
conversation,
|
||||
userMessage,
|
||||
isEdited,
|
||||
isRegenerate,
|
||||
}: Pick<
|
||||
EventSubmission,
|
||||
'conversation' | 'userMessage' | 'isEdited' | 'isRegenerate'
|
||||
>): boolean => {
|
||||
const conversationId = conversation?.conversationId;
|
||||
if (
|
||||
!conversationId ||
|
||||
conversationId === Constants.NEW_CONVO ||
|
||||
conversationId === Constants.PENDING_CONVO
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
isEdited !== true &&
|
||||
isRegenerate !== true &&
|
||||
isInitialNewConversationSubmission({ userMessage })
|
||||
);
|
||||
};
|
||||
|
||||
export const mergeRegenerateFinalMessages = ({
|
||||
messages,
|
||||
responseMessage,
|
||||
|
|
@ -156,6 +187,7 @@ export const getExistingConversationAbortMessages = ({
|
|||
|
||||
export type EventHandlerParams = {
|
||||
isAddedRequest?: boolean;
|
||||
runIndex?: number;
|
||||
setCompleted: React.Dispatch<React.SetStateAction<Set<unknown>>>;
|
||||
setMessages: (messages: TMessage[]) => void;
|
||||
getMessages: () => TMessage[] | undefined;
|
||||
|
|
@ -271,6 +303,7 @@ export default function useEventHandlers({
|
|||
getMessages,
|
||||
setCompleted,
|
||||
isAddedRequest = false,
|
||||
runIndex = 0,
|
||||
setConversation,
|
||||
setIsSubmitting,
|
||||
newConversation,
|
||||
|
|
@ -699,7 +732,10 @@ export default function useEventHandlers({
|
|||
}
|
||||
setMessages([]);
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
|
||||
setDraft({ id: String(Constants.NEW_CONVO), value: requestMessage?.text });
|
||||
setDraft({
|
||||
id: getConversationDraftId(runIndex, Constants.NEW_CONVO),
|
||||
value: requestMessage?.text,
|
||||
});
|
||||
restorePendingQuotes(String(Constants.NEW_CONVO), requestMessage?.quotes);
|
||||
if (location.pathname !== `/c/${Constants.NEW_CONVO}`) {
|
||||
navigate(`/c/${Constants.NEW_CONVO}`, { replace: true });
|
||||
|
|
@ -764,7 +800,10 @@ export default function useEventHandlers({
|
|||
currentConvoId === Constants.NEW_CONVO;
|
||||
|
||||
setFinalMessages(currentConvoId, isNewChat ? [] : [...messages]);
|
||||
setDraft({ id: currentConvoId, value: requestMessage?.text });
|
||||
setDraft({
|
||||
id: getConversationDraftId(runIndex, currentConvoId),
|
||||
value: requestMessage?.text,
|
||||
});
|
||||
restorePendingQuotes(currentConvoId, requestMessage?.quotes);
|
||||
if (isNewChat) {
|
||||
requestChatFocus();
|
||||
|
|
@ -889,6 +928,7 @@ export default function useEventHandlers({
|
|||
setMessages,
|
||||
queryClient,
|
||||
setCompleted,
|
||||
runIndex,
|
||||
isAddedRequest,
|
||||
announcePolite,
|
||||
setConversation,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import type { EventHandlerParams } from './useEventHandlers';
|
|||
import type { TResData } from '~/common';
|
||||
import {
|
||||
logger,
|
||||
clearAllDrafts,
|
||||
clearComposerDrafts,
|
||||
applySteerPart,
|
||||
applyPendingAction,
|
||||
carriedSteerContext,
|
||||
|
|
@ -1044,6 +1044,7 @@ export default function useResumableSSE(
|
|||
getMessages,
|
||||
setCompleted,
|
||||
isAddedRequest,
|
||||
runIndex,
|
||||
setConversation,
|
||||
setIsSubmitting,
|
||||
newConversation,
|
||||
|
|
@ -1563,10 +1564,13 @@ export default function useResumableSSE(
|
|||
conversationId: data.conversation?.conversationId,
|
||||
hasResponseMessage: !!data.responseMessage,
|
||||
});
|
||||
clearAllDrafts(currentSubmission.conversation?.conversationId);
|
||||
if (optimisticStreamIdsRef.current.has(currentStreamId)) {
|
||||
clearAllDrafts(Constants.NEW_CONVO);
|
||||
}
|
||||
clearComposerDrafts(runIndex, currentSubmission.conversation?.conversationId, {
|
||||
includeNewChatDraft:
|
||||
!currentSubmission.conversation?.conversationId ||
|
||||
currentSubmission.conversation.conversationId === Constants.NEW_CONVO ||
|
||||
optimisticStreamIdsRef.current.has(currentStreamId) ||
|
||||
isInitialNewConversation(currentSubmission),
|
||||
});
|
||||
// A steer-applied event may still be waiting for its next-frame
|
||||
// message target when FINAL arrives. Reconcile directly from the
|
||||
// authoritative final message before converting leftovers so a
|
||||
|
|
@ -2454,7 +2458,13 @@ export default function useResumableSSE(
|
|||
resetLive({ ...currentSubmission, userMessage });
|
||||
removeActiveJob(currentStreamId);
|
||||
clearAttachedGenerationCreatedAt();
|
||||
clearAllDrafts(reconciliationConvoId);
|
||||
clearComposerDrafts(runIndex, reconciliationConvoId, {
|
||||
includeNewChatDraft:
|
||||
!reconciliationConvoId ||
|
||||
reconciliationConvoId === Constants.NEW_CONVO ||
|
||||
optimisticStreamIdsRef.current.has(currentStreamId) ||
|
||||
isInitialNewConversation(currentSubmission),
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
setShowStopButton(false);
|
||||
if (event.reconcileReason === 'abort_persistence_failed') {
|
||||
|
|
@ -2535,10 +2545,13 @@ export default function useResumableSSE(
|
|||
/** Terminal: drop any in-flight live estimate so the gauge doesn't
|
||||
* keep counting stale streamed output after the stream ends */
|
||||
resetLive({ ...currentSubmission, userMessage });
|
||||
clearAllDrafts(convoId);
|
||||
if (optimisticStreamIdsRef.current.has(currentStreamId)) {
|
||||
clearAllDrafts(Constants.NEW_CONVO);
|
||||
}
|
||||
clearComposerDrafts(runIndex, convoId, {
|
||||
includeNewChatDraft:
|
||||
!convoId ||
|
||||
convoId === Constants.NEW_CONVO ||
|
||||
optimisticStreamIdsRef.current.has(currentStreamId) ||
|
||||
isInitialNewConversation(currentSubmission),
|
||||
});
|
||||
clearStepMaps();
|
||||
let persistedMessages: TMessage[] | undefined;
|
||||
if (convoId) {
|
||||
|
|
@ -3236,6 +3249,7 @@ export default function useResumableSSE(
|
|||
}
|
||||
},
|
||||
[
|
||||
runIndex,
|
||||
token,
|
||||
setAbortScroll,
|
||||
setActiveRunId,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import type { EventHandlerParams } from './useEventHandlers';
|
||||
import type { TResData } from '~/common';
|
||||
import { clearAllDrafts, applyPendingAction, findPendingActionMessageIndex } from '~/utils';
|
||||
import { clearComposerDrafts, applyPendingAction, findPendingActionMessageIndex } from '~/utils';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
import { startedAsNewConversation } from './useEventHandlers';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import useEventHandlers from './useEventHandlers';
|
||||
import useUsageHandler from './useUsageHandler';
|
||||
|
|
@ -66,6 +67,7 @@ export default function useSSE(
|
|||
getMessages,
|
||||
setCompleted,
|
||||
isAddedRequest,
|
||||
runIndex,
|
||||
setConversation,
|
||||
setIsSubmitting,
|
||||
newConversation,
|
||||
|
|
@ -121,7 +123,9 @@ export default function useSSE(
|
|||
/** A queued delta flush reading the older streaming copy must never
|
||||
* land on top of the server-final write. */
|
||||
cancelPendingDeltaFlush();
|
||||
clearAllDrafts(submission.conversation?.conversationId);
|
||||
clearComposerDrafts(runIndex, submission.conversation?.conversationId, {
|
||||
includeNewChatDraft: startedAsNewConversation(submission),
|
||||
});
|
||||
try {
|
||||
finalHandler(data, submission as EventSubmission);
|
||||
finalizeUsage(data, { ...submission, userMessage });
|
||||
|
|
|
|||
|
|
@ -33,11 +33,13 @@ import {
|
|||
hasModelSelection,
|
||||
buildDefaultConvo,
|
||||
requestChatFocus,
|
||||
renewNewConversationDraftToken,
|
||||
logger,
|
||||
} from '~/utils';
|
||||
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import useGetConversation from './Conversations/useGetConversation';
|
||||
import useAssistantListMap from './Assistants/useAssistantListMap';
|
||||
import { clearUploadRecovery } from './Files/useFileHandling';
|
||||
import { useResetChatBadges } from './useChatBadges';
|
||||
import { useApplyModelSpecEffects } from './Agents';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
|
@ -298,6 +300,7 @@ const useNewConvo = (index = 0) => {
|
|||
disableFocus,
|
||||
buildDefault = true,
|
||||
keepAddedConvos = false,
|
||||
keepComposerState = false,
|
||||
disableParams,
|
||||
}: {
|
||||
template?: Partial<TConversation>;
|
||||
|
|
@ -306,8 +309,21 @@ const useNewConvo = (index = 0) => {
|
|||
buildDefault?: boolean;
|
||||
disableFocus?: boolean;
|
||||
keepAddedConvos?: boolean;
|
||||
/** Set when the call re-renders a composer an earlier call already opened, such as agent
|
||||
* metadata arriving late. The user never left that composer, so its draft identity and its
|
||||
* in-flight attachments outlive the refresh. */
|
||||
keepComposerState?: boolean;
|
||||
disableParams?: boolean;
|
||||
} = {}) {
|
||||
const nextConversationId = _template.conversationId ?? '';
|
||||
const keepsExistingDraft =
|
||||
keepComposerState ||
|
||||
(nextConversationId !== '' &&
|
||||
nextConversationId !== Constants.NEW_CONVO &&
|
||||
!nextConversationId.startsWith('_'));
|
||||
if (!keepsExistingDraft) {
|
||||
renewNewConversationDraftToken(index);
|
||||
}
|
||||
pauseGlobalAudio();
|
||||
if (!saveBadgesState) {
|
||||
resetBadges();
|
||||
|
|
@ -354,22 +370,36 @@ const useNewConvo = (index = 0) => {
|
|||
prevSpecName: prevConversation?.spec,
|
||||
});
|
||||
|
||||
if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) {
|
||||
const filesToDelete = Array.from(files.values())
|
||||
.filter(
|
||||
(file) =>
|
||||
file.filepath != null &&
|
||||
file.filepath !== '' &&
|
||||
file.source &&
|
||||
!(file.embedded ?? false) &&
|
||||
file.temp_file_id,
|
||||
)
|
||||
.map((file) => ({
|
||||
file_id: file.file_id,
|
||||
embedded: !!(file.embedded ?? false),
|
||||
filepath: file.filepath as string,
|
||||
source: file.source as FileSources, // Ensure that the source is of type FileSources
|
||||
}));
|
||||
if (
|
||||
conversation.conversationId === Constants.NEW_CONVO &&
|
||||
!modelsData &&
|
||||
!keepComposerState
|
||||
) {
|
||||
const filesToDelete = Array.from(files.entries()).flatMap(([fileId, file]) => {
|
||||
clearUploadRecovery(fileId);
|
||||
if (file.temp_file_id && file.temp_file_id !== fileId) {
|
||||
clearUploadRecovery(file.temp_file_id);
|
||||
}
|
||||
|
||||
if (
|
||||
file.filepath == null ||
|
||||
file.filepath === '' ||
|
||||
!file.source ||
|
||||
(file.embedded ?? false) ||
|
||||
!file.temp_file_id
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
file_id: file.file_id,
|
||||
embedded: false,
|
||||
filepath: file.filepath,
|
||||
source: file.source as FileSources,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
setFiles(new Map());
|
||||
localStorage.setItem(LocalStorageKeys.FILES_TO_DELETE, JSON.stringify({}));
|
||||
|
|
@ -390,6 +420,7 @@ const useNewConvo = (index = 0) => {
|
|||
);
|
||||
},
|
||||
[
|
||||
index,
|
||||
files,
|
||||
setFiles,
|
||||
agentsMap,
|
||||
|
|
|
|||
|
|
@ -538,6 +538,7 @@
|
|||
"com_nav_info_fork_change_default": "`Visible messages only` includes just the direct path to the selected message. `Include related branches` adds branches along the path. `Include all to/from here` includes all connected messages and branches.",
|
||||
"com_nav_info_fork_split_target_setting": "When enabled, forking will commence from the target message to the latest message in the conversation, according to the behavior selected.",
|
||||
"com_nav_info_latex_parsing": "When enabled, LaTeX code in messages will be rendered as mathematical equations. Disabling this may improve performance if you don't need LaTeX rendering.",
|
||||
"com_nav_info_paste_long_text_as_file": "When enabled, pasting very long text attaches it as a text file instead of filling the message box. File processing limits may apply.",
|
||||
"com_nav_info_save_badges_state": "When enabled, the state of the chat badges will be saved. This means that if you create a new chat, the badges will remain in the same state as the previous chat. If you disable this option, the badges will reset to their default state every time you create a new chat",
|
||||
"com_nav_info_save_draft": "When enabled, the text and attachments you enter in the chat form will be automatically saved locally as drafts. These drafts will be available even if you reload the page or switch to a different conversation. Drafts are stored locally on your device and are deleted once the message is sent.",
|
||||
"com_nav_info_show_thinking": "When enabled, the chat will display the thinking dropdowns open by default, allowing you to view the AI's reasoning in real-time. When disabled, the thinking dropdowns will remain closed by default for a cleaner and more streamlined interface",
|
||||
|
|
@ -612,6 +613,7 @@
|
|||
"com_nav_new_chat_switch_to_history": "Switch to Chat History on new chat",
|
||||
"com_nav_not_supported": "Not Supported",
|
||||
"com_nav_open_sidebar": "Open sidebar",
|
||||
"com_nav_paste_long_text_as_file": "Paste long text as a file",
|
||||
"com_nav_playback_rate": "Audio Playback Rate",
|
||||
"com_nav_plugin_auth_error": "There was an error attempting to authenticate this plugin. Please try again.",
|
||||
"com_nav_plus_command": "+-Command",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,11 @@ const localStorageAtoms = {
|
|||
chatDirection: atomWithLocalStorage('chatDirection', 'LTR'),
|
||||
autoExpandTools: atomWithLocalStorage(LocalStorageKeys.AUTO_EXPAND_TOOLS, false),
|
||||
saveDrafts: atomWithLocalStorage('saveDrafts', true),
|
||||
/**
|
||||
* Whether pasting a large block of text attaches it as a `.txt` file instead of
|
||||
* flooding the composer. The text still reaches the model in full.
|
||||
*/
|
||||
pasteLongTextAsFile: atomWithLocalStorage('pasteLongTextAsFile', true),
|
||||
showScrollButton: atomWithLocalStorage('showScrollButton', true),
|
||||
forkSetting: atomWithLocalStorage('forkSetting', ''),
|
||||
splitAtTarget: atomWithLocalStorage('splitAtTarget', false),
|
||||
|
|
|
|||
202
client/src/utils/__tests__/resolvePastedTextFile.spec.ts
Normal file
202
client/src/utils/__tests__/resolvePastedTextFile.spec.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { EToolResources } from 'librechat-data-provider';
|
||||
import type { FileConfig } from 'librechat-data-provider';
|
||||
import {
|
||||
getViableUploadOptions,
|
||||
resolvePastedTextFile,
|
||||
PASTE_AS_FILE_MIN_LENGTH,
|
||||
PASTED_TEXT_FILENAME,
|
||||
type UploadOptionContext,
|
||||
type PasteAsFileContext,
|
||||
} from '../files';
|
||||
|
||||
/** context accepts plain text; matches the shape the app ships for text uploads */
|
||||
const fileConfig = {
|
||||
text: { supportedMimeTypes: [/^text\/(plain|csv)$/] },
|
||||
ocr: { supportedMimeTypes: [] },
|
||||
stt: { supportedMimeTypes: [] },
|
||||
} as unknown as FileConfig;
|
||||
|
||||
const uploadCtx = (over: Partial<UploadOptionContext> = {}): UploadOptionContext => ({
|
||||
provider: 'anthropic',
|
||||
endpoint: 'anthropic',
|
||||
endpointType: 'anthropic',
|
||||
useResponsesApi: false,
|
||||
fileSearchEnabled: false,
|
||||
codeEnabled: false,
|
||||
contextEnabled: true,
|
||||
fileSearchAllowedByAgent: true,
|
||||
codeAllowedByAgent: true,
|
||||
fileConfig,
|
||||
...over,
|
||||
});
|
||||
|
||||
/** The real option resolver, so these tests exercise production routing rules */
|
||||
const realOptions =
|
||||
(over: Partial<UploadOptionContext> = {}) =>
|
||||
(files: File[]) =>
|
||||
getViableUploadOptions(files, uploadCtx(over));
|
||||
|
||||
const baseCtx = (over: Partial<PasteAsFileContext> = {}): PasteAsFileContext => ({
|
||||
enabled: true,
|
||||
uploadsDisabled: false,
|
||||
isAssistants: false,
|
||||
attachedFilenames: new Set<string>(),
|
||||
configPending: false,
|
||||
getOptions: realOptions(),
|
||||
...over,
|
||||
});
|
||||
|
||||
const longText = 'a'.repeat(PASTE_AS_FILE_MIN_LENGTH + 1);
|
||||
const thresholdText = 'a'.repeat(PASTE_AS_FILE_MIN_LENGTH);
|
||||
const shortText = 'a'.repeat(PASTE_AS_FILE_MIN_LENGTH - 1);
|
||||
|
||||
/** jsdom's File has no `text()`, so read it the way the browser upload path would */
|
||||
const readFile = (file: File) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
describe('resolvePastedTextFile', () => {
|
||||
it('attaches a long paste as a plain text file routed to context', async () => {
|
||||
const attachment = resolvePastedTextFile(longText, baseCtx());
|
||||
|
||||
expect(attachment?.file).toBeInstanceOf(File);
|
||||
expect(attachment?.file.name).toBe(PASTED_TEXT_FILENAME);
|
||||
expect(attachment?.file.type).toBe('text/plain');
|
||||
expect(attachment?.toolResource).toBe(EToolResources.context);
|
||||
await expect(readFile(attachment?.file as File)).resolves.toBe(longText);
|
||||
});
|
||||
|
||||
it('leaves a paste one character below the threshold inline', () => {
|
||||
expect(resolvePastedTextFile(shortText, baseCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a paste at the threshold inline', () => {
|
||||
expect(resolvePastedTextFile(thresholdText, baseCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves an empty paste inline', () => {
|
||||
expect(resolvePastedTextFile('', baseCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the paste inline when the setting is off', () => {
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ enabled: false }))).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the paste inline when uploads are disabled for the endpoint', () => {
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ uploadsDisabled: true }))).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the paste inline when no destination accepts a text file', () => {
|
||||
const getOptions = realOptions({
|
||||
contextEnabled: false,
|
||||
fileSearchEnabled: false,
|
||||
codeEnabled: false,
|
||||
});
|
||||
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ getOptions }))).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the paste inline when context is unavailable even if file search is viable', () => {
|
||||
const getOptions = realOptions({ contextEnabled: false, fileSearchEnabled: true });
|
||||
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ getOptions }))).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers context over file search rather than prompting', () => {
|
||||
const getOptions = realOptions({ fileSearchEnabled: true, codeEnabled: true });
|
||||
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ getOptions }))?.toolResource).toBe(
|
||||
EToolResources.context,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the paste inline when several destinations compete without context', () => {
|
||||
const getOptions = realOptions({
|
||||
contextEnabled: false,
|
||||
fileSearchEnabled: true,
|
||||
codeEnabled: true,
|
||||
});
|
||||
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ getOptions }))).toBeNull();
|
||||
});
|
||||
|
||||
it('routes a long paste while the file config has not arrived', () => {
|
||||
const loading = realOptions({ fileConfig: null });
|
||||
/** The MIME lists live in that config, so nothing is viable until it lands */
|
||||
expect(loading([new File(['x'], 'notes.txt', { type: 'text/plain' })])).toEqual([]);
|
||||
|
||||
const attachment = resolvePastedTextFile(
|
||||
longText,
|
||||
baseCtx({ configPending: true, getOptions: loading }),
|
||||
);
|
||||
|
||||
expect(attachment?.file.name).toBe(PASTED_TEXT_FILENAME);
|
||||
expect(attachment?.toolResource).toBe(EToolResources.context);
|
||||
});
|
||||
|
||||
it('leaves the paste inline once a loaded config offers no context destination', () => {
|
||||
const getOptions = realOptions({ fileConfig: null });
|
||||
|
||||
expect(
|
||||
resolvePastedTextFile(longText, baseCtx({ configPending: false, getOptions })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('skips option resolution for assistants, which route their own uploads', () => {
|
||||
const getOptions = jest.fn(() => []);
|
||||
const attachment = resolvePastedTextFile(longText, baseCtx({ isAssistants: true, getOptions }));
|
||||
|
||||
expect(attachment?.file).toBeInstanceOf(File);
|
||||
expect(attachment?.toolResource).toBeUndefined();
|
||||
expect(getOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('naming successive pastes', () => {
|
||||
it('numbers the next paste so a same-length paste is not seen as a duplicate', () => {
|
||||
const attachedFilenames = new Set([PASTED_TEXT_FILENAME]);
|
||||
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ attachedFilenames }))?.file.name).toBe(
|
||||
'pasted-text-2.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps counting past the numbered names already attached', () => {
|
||||
const attachedFilenames = new Set([
|
||||
PASTED_TEXT_FILENAME,
|
||||
'pasted-text-2.txt',
|
||||
'pasted-text-3.txt',
|
||||
]);
|
||||
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ attachedFilenames }))?.file.name).toBe(
|
||||
'pasted-text-4.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses a freed name when an earlier paste was removed', () => {
|
||||
const attachedFilenames = new Set(['pasted-text-2.txt']);
|
||||
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ attachedFilenames }))?.file.name).toBe(
|
||||
PASTED_TEXT_FILENAME,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores unrelated attachments when picking the name', () => {
|
||||
const attachedFilenames = new Set(['report.pdf', 'notes.txt']);
|
||||
|
||||
expect(resolvePastedTextFile(longText, baseCtx({ attachedFilenames }))?.file.name).toBe(
|
||||
PASTED_TEXT_FILENAME,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not resolve options for a paste that is too short to attach', () => {
|
||||
const getOptions = jest.fn(() => []);
|
||||
resolvePastedTextFile(shortText, baseCtx({ getOptions }));
|
||||
|
||||
expect(getOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
497
client/src/utils/drafts.spec.ts
Normal file
497
client/src/utils/drafts.spec.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import {
|
||||
applyPendingPasteToDraft,
|
||||
applyPendingPastesToDraft,
|
||||
clearComposerDrafts,
|
||||
decodeBase64,
|
||||
encodeBase64,
|
||||
resolvePendingPasteInsertStart,
|
||||
getComposerDraftId,
|
||||
getDraft,
|
||||
getFilesDraft,
|
||||
getNewConversationDraftId,
|
||||
getNewConversationDraftToken,
|
||||
getPendingDraftId,
|
||||
isNewConversationDraftId,
|
||||
migrateFilesDraft,
|
||||
migrateTextDraft,
|
||||
renewNewConversationDraftToken,
|
||||
setDraft,
|
||||
setFilesDraft,
|
||||
setPendingTextAttachmentDraft,
|
||||
} from './drafts';
|
||||
|
||||
describe('new-conversation draft tokens', () => {
|
||||
it('keeps tokens independent across composer indexes', () => {
|
||||
const firstPaneToken = getNewConversationDraftToken(0);
|
||||
const secondPaneToken = getNewConversationDraftToken(1);
|
||||
|
||||
expect(firstPaneToken).not.toBe(secondPaneToken);
|
||||
|
||||
renewNewConversationDraftToken(1);
|
||||
|
||||
expect(getNewConversationDraftToken(0)).toBe(firstPaneToken);
|
||||
expect(getNewConversationDraftToken(1)).not.toBe(secondPaneToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingDraftId', () => {
|
||||
it('keeps the primary composer on the historical PENDING key', () => {
|
||||
expect(getPendingDraftId()).toBe(Constants.PENDING_CONVO);
|
||||
expect(getPendingDraftId(0)).toBe(Constants.PENDING_CONVO);
|
||||
});
|
||||
|
||||
it('suffixes additional composer indexes', () => {
|
||||
expect(getPendingDraftId(1)).toBe(`${Constants.PENDING_CONVO}:1`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewConversationDraftId', () => {
|
||||
it('keeps the primary composer on the historical NEW_CONVO key', () => {
|
||||
expect(getNewConversationDraftId()).toBe(Constants.NEW_CONVO);
|
||||
expect(getNewConversationDraftId(0)).toBe(Constants.NEW_CONVO);
|
||||
});
|
||||
|
||||
it('suffixes additional composer indexes', () => {
|
||||
expect(getNewConversationDraftId(1)).toBe(`${Constants.NEW_CONVO}:1`);
|
||||
});
|
||||
|
||||
it('treats suffixed keys as new-conversation drafts', () => {
|
||||
expect(isNewConversationDraftId(Constants.NEW_CONVO)).toBe(true);
|
||||
expect(isNewConversationDraftId(`${Constants.NEW_CONVO}:1`)).toBe(true);
|
||||
expect(isNewConversationDraftId('convo-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('scopes idle unsaved drafts and in-flight drafts separately', () => {
|
||||
expect(getComposerDraftId(1, Constants.NEW_CONVO)).toBe(`${Constants.NEW_CONVO}:1`);
|
||||
expect(getComposerDraftId(1, Constants.NEW_CONVO, true)).toBe(`${Constants.PENDING_CONVO}:1`);
|
||||
expect(getComposerDraftId(1, 'convo-side')).toBe('convo-side');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPendingPasteToDraft', () => {
|
||||
it('replaces a stale selected range when the original text is still present', () => {
|
||||
expect(
|
||||
applyPendingPasteToDraft('before selected after', {
|
||||
text: 'pasted',
|
||||
selectionStart: 7,
|
||||
selectionEnd: 15,
|
||||
replacedText: 'selected',
|
||||
}),
|
||||
).toBe('before pasted after');
|
||||
});
|
||||
|
||||
it('inserts at the caret when the selected range is already gone', () => {
|
||||
expect(
|
||||
applyPendingPasteToDraft('before after', {
|
||||
text: 'pasted',
|
||||
selectionStart: 7,
|
||||
selectionEnd: 15,
|
||||
replacedText: 'selected',
|
||||
}),
|
||||
).toBe('before pasted after');
|
||||
});
|
||||
|
||||
it('inserts at the caret when no replacement range was stored', () => {
|
||||
expect(
|
||||
applyPendingPasteToDraft('before after', {
|
||||
text: 'pasted',
|
||||
selectionStart: 7,
|
||||
}),
|
||||
).toBe('before pasted after');
|
||||
});
|
||||
|
||||
it('does not delete remaining identical text after a post-replacement snapshot', () => {
|
||||
expect(
|
||||
applyPendingPasteToDraft('abc', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 0,
|
||||
replacedText: 'abc',
|
||||
replacedApplied: true,
|
||||
anchorBefore: '',
|
||||
anchorAfter: 'abc',
|
||||
}),
|
||||
).toBe('PASTEabc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPendingPastesToDraft', () => {
|
||||
it('rebases an earlier end replacement after a later start replacement', () => {
|
||||
const pastes = [
|
||||
{
|
||||
text: 'END',
|
||||
selectionStart: 10,
|
||||
selectionEnd: 14,
|
||||
replacedText: 'CCCC',
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
text: 'START',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
replacedText: 'AAAA',
|
||||
sequence: 2,
|
||||
},
|
||||
];
|
||||
|
||||
expect(applyPendingPastesToDraft('AAAA BBBB CCCC', pastes)).toBe('START BBBB END');
|
||||
expect(applyPendingPastesToDraft(' BBBB ', pastes)).toBe('START BBBB END');
|
||||
});
|
||||
|
||||
it('rebases an insert after the user edits text before the original caret', () => {
|
||||
expect(
|
||||
applyPendingPasteToDraft('Xhello', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 5,
|
||||
replacedApplied: true,
|
||||
anchorBefore: 'hello',
|
||||
anchorAfter: '',
|
||||
}),
|
||||
).toBe('XhelloPASTE');
|
||||
});
|
||||
|
||||
it('rebases an insert past a prefix the user duplicated', () => {
|
||||
expect(
|
||||
applyPendingPasteToDraft('aabc', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 1,
|
||||
replacedApplied: true,
|
||||
anchorBefore: 'a',
|
||||
anchorAfter: 'bc',
|
||||
}),
|
||||
).toBe('aaPASTEbc');
|
||||
});
|
||||
|
||||
it('keeps a leading insert ahead of a suffix the user duplicated', () => {
|
||||
expect(
|
||||
applyPendingPasteToDraft('abcabc', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 0,
|
||||
replacedApplied: true,
|
||||
anchorBefore: '',
|
||||
anchorAfter: 'abc',
|
||||
}),
|
||||
).toBe('PASTEabcabc');
|
||||
});
|
||||
|
||||
it('rebases an insert when both sides of the original caret were edited', () => {
|
||||
expect(
|
||||
applyPendingPasteToDraft('XhelloWORLDY', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 5,
|
||||
replacedApplied: true,
|
||||
anchorBefore: 'hello',
|
||||
anchorAfter: 'WORLD',
|
||||
}),
|
||||
).toBe('XhelloPASTEWORLDY');
|
||||
});
|
||||
|
||||
it('keeps a later replacement anchored after an earlier middle removal', () => {
|
||||
expect(
|
||||
applyPendingPastesToDraft('0123456789', [
|
||||
{
|
||||
text: 'MID',
|
||||
selectionStart: 2,
|
||||
replacedText: '234',
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
text: 'TAIL',
|
||||
selectionStart: 5,
|
||||
replacedText: '89',
|
||||
sequence: 2,
|
||||
},
|
||||
]),
|
||||
).toBe('01MID567TAIL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearComposerDrafts', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('clears pane-scoped pending and new-chat keys without touching another pane', () => {
|
||||
setDraft({ id: Constants.NEW_CONVO as string, value: 'pane 0 new' });
|
||||
setDraft({ id: `${Constants.NEW_CONVO}:1`, value: 'pane 1 new' });
|
||||
setDraft({ id: Constants.PENDING_CONVO as string, value: 'pane 0 pending' });
|
||||
setDraft({ id: `${Constants.PENDING_CONVO}:1`, value: 'pane 1 pending' });
|
||||
setFilesDraft(`${Constants.PENDING_CONVO}:1`, {
|
||||
fileIds: ['pane-1-file'],
|
||||
pendingPastes: {
|
||||
'pane-1-file': { text: 'paste', selectionStart: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
clearComposerDrafts(1, Constants.NEW_CONVO as string);
|
||||
|
||||
expect(getDraft(Constants.NEW_CONVO)).toBe('pane 0 new');
|
||||
expect(getDraft(Constants.PENDING_CONVO)).toBe('pane 0 pending');
|
||||
expect(
|
||||
localStorage.getItem(`${LocalStorageKeys.TEXT_DRAFT}${Constants.NEW_CONVO}:1`),
|
||||
).toBeNull();
|
||||
expect(getDraft(`${Constants.PENDING_CONVO}:1`)).toBe('pane 1 pending');
|
||||
expect(
|
||||
localStorage.getItem(`${LocalStorageKeys.FILES_DRAFT}${Constants.PENDING_CONVO}:1`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not clear an unrelated new-chat draft when a saved conversation finishes', () => {
|
||||
setDraft({ id: `${Constants.NEW_CONVO}:1`, value: 'unsent new chat' });
|
||||
setDraft({ id: 'convo-side', value: 'sent message leftover' });
|
||||
|
||||
clearComposerDrafts(1, 'convo-side');
|
||||
|
||||
expect(getDraft(`${Constants.NEW_CONVO}:1`)).toBe('unsent new chat');
|
||||
expect(getDraft('convo-side')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending paste encoding', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
/** Past the argument limit that a spread into String.fromCharCode blows */
|
||||
const hugePaste = `${'a'.repeat(200000)} café 🧪`;
|
||||
|
||||
it('round-trips a paste far larger than the call argument limit', () => {
|
||||
expect(decodeBase64(encodeBase64(hugePaste))).toBe(hugePaste);
|
||||
});
|
||||
|
||||
it('stores and reads back a huge pending paste', () => {
|
||||
setPendingTextAttachmentDraft({
|
||||
id: 'convo-1',
|
||||
fileId: 'file-1',
|
||||
text: hugePaste,
|
||||
selectionStart: 0,
|
||||
});
|
||||
|
||||
expect(getFilesDraft('convo-1').pendingPastes['file-1']?.text).toBe(hugePaste);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateFilesDraft', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('moves the record to the destination key', () => {
|
||||
setFilesDraft('pending', {
|
||||
fileIds: ['file-1'],
|
||||
pendingPastes: { 'file-1': { text: 'pasted', selectionStart: 0 } },
|
||||
});
|
||||
|
||||
expect(migrateFilesDraft('pending', 'convo-1')).toBe('convo-1');
|
||||
expect(getFilesDraft('convo-1').pendingPastes['file-1']?.text).toBe('pasted');
|
||||
expect(getFilesDraft('pending')).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
});
|
||||
|
||||
it('never holds the record under both keys at once', () => {
|
||||
setFilesDraft('pending', { fileIds: ['file-1'], pendingPastes: {} });
|
||||
const realSetItem = Storage.prototype.setItem;
|
||||
const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
|
||||
this: Storage,
|
||||
key: string,
|
||||
value: string,
|
||||
) {
|
||||
if (key === `${LocalStorageKeys.FILES_DRAFT}convo-1`) {
|
||||
expect(localStorage.getItem(`${LocalStorageKeys.FILES_DRAFT}pending`)).toBeNull();
|
||||
}
|
||||
realSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
expect(migrateFilesDraft('pending', 'convo-1')).toBe('convo-1');
|
||||
setItem.mockRestore();
|
||||
});
|
||||
|
||||
it('leaves the record where it was when the destination write fails', () => {
|
||||
setFilesDraft('pending', {
|
||||
fileIds: ['file-1'],
|
||||
pendingPastes: { 'file-1': { text: 'pasted', selectionStart: 0 } },
|
||||
});
|
||||
const realSetItem = Storage.prototype.setItem;
|
||||
const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
|
||||
this: Storage,
|
||||
key: string,
|
||||
value: string,
|
||||
) {
|
||||
if (key === `${LocalStorageKeys.FILES_DRAFT}convo-1`) {
|
||||
throw new Error('quota exceeded');
|
||||
}
|
||||
realSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
expect(migrateFilesDraft('pending', 'convo-1')).toBe('pending');
|
||||
setItem.mockRestore();
|
||||
expect(getFilesDraft('pending').pendingPastes['file-1']?.text).toBe('pasted');
|
||||
expect(getFilesDraft('convo-1')).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
});
|
||||
|
||||
it('reports the destination when there is nothing to move', () => {
|
||||
expect(migrateFilesDraft('pending', 'convo-1')).toBe('convo-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateTextDraft', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('moves the draft and reports that it did', () => {
|
||||
setDraft({ id: 'pending', value: 'carried over' });
|
||||
|
||||
expect(migrateTextDraft('pending', 'convo-1')).toBe(true);
|
||||
expect(getDraft('convo-1')).toBe('carried over');
|
||||
expect(getDraft('pending')).toBe('');
|
||||
});
|
||||
|
||||
it('reports nothing moved when the source is empty', () => {
|
||||
expect(migrateTextDraft('pending', 'convo-1')).toBe(false);
|
||||
expect(getDraft('convo-1')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDraft persistExact', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('drops a one-character value by default', () => {
|
||||
setDraft({ id: 'convo-1', value: 'x' });
|
||||
expect(getDraft('convo-1')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps a one-character snapshot when persistExact is set', () => {
|
||||
setDraft({ id: 'convo-1', value: 'x', persistExact: true });
|
||||
expect(getDraft('convo-1')).toBe('x');
|
||||
});
|
||||
|
||||
it('does not throw when localStorage.setItem fails', () => {
|
||||
const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota exceeded');
|
||||
});
|
||||
expect(() =>
|
||||
setDraft({ id: 'convo-1', value: 'draft text', persistExact: true }),
|
||||
).not.toThrow();
|
||||
setItem.mockRestore();
|
||||
});
|
||||
|
||||
it('returns empty drafts when localStorage.getItem throws', () => {
|
||||
setDraft({ id: 'convo-1', value: 'draft text', persistExact: true });
|
||||
setFilesDraft('convo-1', {
|
||||
fileIds: ['file-1'],
|
||||
pendingPastes: { 'file-1': { text: 'paste', selectionStart: 0 } },
|
||||
});
|
||||
const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked');
|
||||
});
|
||||
|
||||
expect(getFilesDraft('convo-1')).toEqual({ fileIds: [], pendingPastes: {} });
|
||||
expect(getDraft('convo-1')).toBe('');
|
||||
getItem.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePendingPasteInsertStart', () => {
|
||||
it('moves the caret when text is prepended before the original snapshot', () => {
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('Xhello', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 5,
|
||||
anchorBefore: 'hello',
|
||||
anchorAfter: '',
|
||||
}),
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('finds the original junction when both sides of the caret were edited', () => {
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('XhelloWORLDY', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 5,
|
||||
anchorBefore: 'hello',
|
||||
anchorAfter: 'WORLD',
|
||||
}),
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('picks the junction the anchors still meet at when an edit duplicates the prefix', () => {
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('aabc', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 1,
|
||||
anchorBefore: 'a',
|
||||
anchorAfter: 'bc',
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the duplicated prefix junction when the tail was edited too', () => {
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('aabcX', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 1,
|
||||
anchorBefore: 'a',
|
||||
anchorAfter: 'bc',
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('holds the captured caret when a duplicated prefix leaves the junction ambiguous', () => {
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('helloXhello', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 5,
|
||||
anchorBefore: 'hello',
|
||||
anchorAfter: '',
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it('holds the captured caret when the suffix is appended to itself', () => {
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('abcabc', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 0,
|
||||
anchorBefore: '',
|
||||
anchorAfter: 'abc',
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('still trails a suffix the user edited out of recognition', () => {
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('ZZWORLD', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 5,
|
||||
anchorBefore: 'hello',
|
||||
anchorAfter: 'WORLD',
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('stays at the captured caret when an edit duplicates both anchors', () => {
|
||||
/** `abcabc` is what both prepending and appending `abc` to `abc` produce, so the junction
|
||||
* could be 1 or 4 and nothing in the saved state says which. The caret is the tiebreak. */
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('abcabc', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 1,
|
||||
anchorBefore: 'a',
|
||||
anchorAfter: 'bc',
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to the captured caret when both anchors were empty', () => {
|
||||
expect(
|
||||
resolvePendingPasteInsertStart('typed since', {
|
||||
text: 'PASTE',
|
||||
selectionStart: 0,
|
||||
anchorBefore: '',
|
||||
anchorAfter: '',
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +1,322 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
|
||||
export type PendingTextAttachmentDraft = {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd?: number;
|
||||
replacedText?: string;
|
||||
sequence?: number;
|
||||
/** True when TEXT_DRAFT was written after the selection was already removed. */
|
||||
replacedApplied?: boolean;
|
||||
anchorBefore?: string;
|
||||
anchorAfter?: string;
|
||||
};
|
||||
|
||||
export type FilesDraft = {
|
||||
fileIds: string[];
|
||||
pendingPastes: Record<string, PendingTextAttachmentDraft>;
|
||||
};
|
||||
|
||||
type StoredPendingTextAttachmentDraft = {
|
||||
encodedText: string;
|
||||
selectionStart: number;
|
||||
selectionEnd?: number;
|
||||
encodedReplacedText?: string;
|
||||
sequence?: number;
|
||||
replacedApplied?: boolean;
|
||||
encodedAnchorBefore?: string;
|
||||
encodedAnchorAfter?: string;
|
||||
};
|
||||
|
||||
type StoredFilesDraft = {
|
||||
fileIds: string[];
|
||||
pendingPastes: Record<string, StoredPendingTextAttachmentDraft>;
|
||||
};
|
||||
|
||||
const newConversationDraftTokens = new Map<number, symbol>();
|
||||
|
||||
/** Per-composer identity so a side-by-side new-chat reset cannot discard another pane's paste recovery. */
|
||||
export const getNewConversationDraftToken = (index = 0): symbol => {
|
||||
const existing = newConversationDraftTokens.get(index);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const token = Symbol('new-conversation-draft');
|
||||
newConversationDraftTokens.set(index, token);
|
||||
return token;
|
||||
};
|
||||
|
||||
export const renewNewConversationDraftToken = (index = 0): void => {
|
||||
newConversationDraftTokens.set(index, Symbol('new-conversation-draft'));
|
||||
};
|
||||
|
||||
/** Draft key used while a run is in flight. Extra panes get a suffix so one run cannot migrate another pane's attachments. */
|
||||
export const getPendingDraftId = (index = 0): string =>
|
||||
index === 0 ? Constants.PENDING_CONVO : `${Constants.PENDING_CONVO}:${index}`;
|
||||
|
||||
/** Draft key for an idle unsaved chat. Extra panes get a suffix so two new composers do not share FILES_DRAFT. */
|
||||
export const getNewConversationDraftId = (index = 0): string =>
|
||||
index === 0 ? Constants.NEW_CONVO : `${Constants.NEW_CONVO}:${index}`;
|
||||
|
||||
export const isNewConversationDraftId = (id?: string | null): boolean =>
|
||||
typeof id === 'string' &&
|
||||
(id === Constants.NEW_CONVO || id.startsWith(`${Constants.NEW_CONVO}:`));
|
||||
|
||||
export const getConversationDraftId = (index = 0, conversationId?: string | null): string =>
|
||||
conversationId == null || conversationId === '' || conversationId === Constants.NEW_CONVO
|
||||
? getNewConversationDraftId(index)
|
||||
: conversationId;
|
||||
|
||||
export const getComposerDraftId = (
|
||||
index = 0,
|
||||
conversationId?: string | null,
|
||||
isSubmitting = false,
|
||||
): string =>
|
||||
isSubmitting ? getPendingDraftId(index) : getConversationDraftId(index, conversationId);
|
||||
|
||||
const getReplacedLength = (pendingPaste: PendingTextAttachmentDraft): number => {
|
||||
if (pendingPaste.replacedText != null && pendingPaste.replacedText.length > 0) {
|
||||
return pendingPaste.replacedText.length;
|
||||
}
|
||||
if (pendingPaste.selectionEnd != null) {
|
||||
return Math.max(0, pendingPaste.selectionEnd - pendingPaste.selectionStart);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const findAnchoredInsertStart = (
|
||||
draftText: string,
|
||||
before: string,
|
||||
after: string,
|
||||
): number | null => {
|
||||
if (before && after) {
|
||||
let insertStart: number | null = null;
|
||||
let searchFrom = 0;
|
||||
while (searchFrom <= draftText.length) {
|
||||
const beforeIndex = draftText.indexOf(before, searchFrom);
|
||||
if (beforeIndex < 0) {
|
||||
break;
|
||||
}
|
||||
const candidate = beforeIndex + before.length;
|
||||
if (draftText.indexOf(after, candidate) >= 0) {
|
||||
insertStart = candidate;
|
||||
}
|
||||
searchFrom = beforeIndex + 1;
|
||||
}
|
||||
if (insertStart != null) {
|
||||
return insertStart;
|
||||
}
|
||||
}
|
||||
if (before) {
|
||||
const beforeIndex = draftText.lastIndexOf(before);
|
||||
if (beforeIndex >= 0) {
|
||||
return beforeIndex + before.length;
|
||||
}
|
||||
}
|
||||
if (after) {
|
||||
const afterIndex = draftText.indexOf(after);
|
||||
if (afterIndex >= 0) {
|
||||
return afterIndex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The offset closest to the original caret where both captured anchors survived the edit intact
|
||||
* and still meet. A repeated anchor leaves several such offsets, and the caret is the only
|
||||
* evidence left of which one the paste belongs to; the scan opens there, so the first hit is it.
|
||||
*/
|
||||
const findIntactAnchorJunction = (
|
||||
draftText: string,
|
||||
before: string,
|
||||
after: string,
|
||||
): number | null => {
|
||||
if (before === '' && after === '') {
|
||||
return null;
|
||||
}
|
||||
const lastJunction = draftText.length - after.length;
|
||||
for (let start = before.length; start <= lastJunction; start++) {
|
||||
if (!draftText.startsWith(before, start - before.length)) {
|
||||
continue;
|
||||
}
|
||||
if (draftText.startsWith(after, start)) {
|
||||
return start;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolvePendingPasteInsertStart = (
|
||||
draftText: string,
|
||||
pendingPaste: PendingTextAttachmentDraft,
|
||||
): number => {
|
||||
const prefix = pendingPaste.anchorBefore;
|
||||
const suffix = pendingPaste.anchorAfter;
|
||||
if (prefix == null && suffix == null) {
|
||||
return Math.min(pendingPaste.selectionStart, draftText.length);
|
||||
}
|
||||
const before = prefix ?? '';
|
||||
const after = suffix ?? '';
|
||||
if (draftText === `${before}${after}`) {
|
||||
return before.length;
|
||||
}
|
||||
const intactJunction = findIntactAnchorJunction(draftText, before, after);
|
||||
if (intactJunction != null) {
|
||||
return intactJunction;
|
||||
}
|
||||
if (before && draftText.startsWith(before)) {
|
||||
return before.length;
|
||||
}
|
||||
if (after !== '' && draftText.endsWith(after)) {
|
||||
return draftText.length - after.length;
|
||||
}
|
||||
if (after === '' && before && draftText.endsWith(before)) {
|
||||
return draftText.length;
|
||||
}
|
||||
return (
|
||||
findAnchoredInsertStart(draftText, before, after) ??
|
||||
Math.min(pendingPaste.selectionStart, draftText.length)
|
||||
);
|
||||
};
|
||||
|
||||
export const applyPendingPasteToDraft = (
|
||||
draftText: string,
|
||||
pendingPaste: PendingTextAttachmentDraft,
|
||||
): string => applyPendingPastesToDraft(draftText, [pendingPaste]);
|
||||
|
||||
/** Replay leftover pre-deletion ranges, then insert paste text at rebased or anchored offsets. */
|
||||
export const applyPendingPastesToDraft = (
|
||||
draftText: string,
|
||||
pendingPastes: PendingTextAttachmentDraft[],
|
||||
): string => {
|
||||
if (pendingPastes.length === 0) {
|
||||
return draftText;
|
||||
}
|
||||
|
||||
const ordered = pendingPastes.map((pendingPaste, index) => ({ pendingPaste, index }));
|
||||
ordered.sort(
|
||||
(a, b) =>
|
||||
(a.pendingPaste.sequence ?? a.index) - (b.pendingPaste.sequence ?? b.index) ||
|
||||
a.index - b.index,
|
||||
);
|
||||
|
||||
let text = draftText;
|
||||
for (const { pendingPaste } of ordered) {
|
||||
if (pendingPaste.replacedApplied) {
|
||||
continue;
|
||||
}
|
||||
const replacedText = pendingPaste.replacedText ?? '';
|
||||
const start = Math.min(pendingPaste.selectionStart, text.length);
|
||||
if (
|
||||
replacedText.length > 0 &&
|
||||
text.slice(start, start + replacedText.length) === replacedText
|
||||
) {
|
||||
text = `${text.slice(0, start)}${text.slice(start + replacedText.length)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const insertions = ordered.map(({ pendingPaste }, index) => {
|
||||
if (pendingPaste.replacedApplied) {
|
||||
return {
|
||||
text: pendingPaste.text,
|
||||
start: resolvePendingPasteInsertStart(draftText, pendingPaste),
|
||||
index,
|
||||
};
|
||||
}
|
||||
let start = pendingPaste.selectionStart;
|
||||
for (const later of ordered.slice(index + 1)) {
|
||||
if (later.pendingPaste.selectionStart < start) {
|
||||
start -= getReplacedLength(later.pendingPaste);
|
||||
}
|
||||
}
|
||||
return { text: pendingPaste.text, start: Math.max(0, start), index };
|
||||
});
|
||||
insertions.sort((a, b) => b.start - a.start || b.index - a.index);
|
||||
|
||||
for (const insertion of insertions) {
|
||||
const start = Math.min(insertion.start, text.length);
|
||||
text = `${text.slice(0, start)}${insertion.text}${text.slice(start)}`;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const getLocalStorageItem = (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
// Privacy-blocked storage must not abort paste/upload recovery.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setLocalStorageItem = (key: string, value: string): void => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Quota or disabled storage must not abort paste/upload recovery.
|
||||
}
|
||||
};
|
||||
|
||||
const removeLocalStorageItem = (key: string): void => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore storage failures on cleanup.
|
||||
}
|
||||
};
|
||||
|
||||
export const clearDraft = debounce((id?: string | null) => {
|
||||
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${id ?? ''}`);
|
||||
removeLocalStorageItem(`${LocalStorageKeys.TEXT_DRAFT}${id ?? ''}`);
|
||||
}, 2500);
|
||||
|
||||
/** Synchronously removes both text and file drafts for a conversation (or NEW_CONVO fallback) */
|
||||
export const clearAllDrafts = (conversationId?: string | null) => {
|
||||
const key = conversationId || Constants.NEW_CONVO;
|
||||
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${key}`);
|
||||
localStorage.removeItem(`${LocalStorageKeys.FILES_DRAFT}${key}`);
|
||||
removeLocalStorageItem(`${LocalStorageKeys.TEXT_DRAFT}${key}`);
|
||||
removeLocalStorageItem(`${LocalStorageKeys.FILES_DRAFT}${key}`);
|
||||
};
|
||||
|
||||
/** Clears this pane's concrete conversation draft. The idle new-chat key is only removed when the finished run originated as an unsaved chat. Leaves PENDING so unsent during-run attachments can migrate. */
|
||||
export const clearComposerDrafts = (
|
||||
index = 0,
|
||||
conversationId?: string | null,
|
||||
options?: { includeNewChatDraft?: boolean },
|
||||
): void => {
|
||||
const originatedFromNewChat =
|
||||
options?.includeNewChatDraft ??
|
||||
(conversationId == null ||
|
||||
conversationId === '' ||
|
||||
conversationId === Constants.NEW_CONVO ||
|
||||
isNewConversationDraftId(conversationId));
|
||||
const keys = new Set<string>();
|
||||
if (originatedFromNewChat) {
|
||||
keys.add(getNewConversationDraftId(index));
|
||||
}
|
||||
if (conversationId != null && conversationId !== '') {
|
||||
keys.add(getConversationDraftId(index, conversationId));
|
||||
if (conversationId !== Constants.NEW_CONVO && conversationId !== Constants.PENDING_CONVO) {
|
||||
keys.add(conversationId);
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
clearAllDrafts(key);
|
||||
}
|
||||
};
|
||||
|
||||
/** Spreading a whole paste into `String.fromCharCode` blows the argument limit, and the paste
|
||||
* sizes this recovery exists for are exactly the ones that reach it. */
|
||||
const BINARY_STRING_CHUNK = 0x8000;
|
||||
|
||||
export const encodeBase64 = (plainText: string): string => {
|
||||
try {
|
||||
const textBytes = new TextEncoder().encode(plainText);
|
||||
return btoa(String.fromCharCode(...textBytes));
|
||||
let binary = '';
|
||||
for (let start = 0; start < textBytes.length; start += BINARY_STRING_CHUNK) {
|
||||
binary += String.fromCharCode(...textBytes.subarray(start, start + BINARY_STRING_CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
|
@ -34,16 +335,222 @@ export const decodeBase64 = (base64String: string): string => {
|
|||
}
|
||||
};
|
||||
|
||||
export const setDraft = ({ id, value }: { id: string; value?: string }) => {
|
||||
if (value && value.length > 1) {
|
||||
localStorage.setItem(`${LocalStorageKeys.TEXT_DRAFT}${id}`, encodeBase64(value));
|
||||
export const getFilesDraft = (id: string): FilesDraft => {
|
||||
const storedValue = getLocalStorageItem(`${LocalStorageKeys.FILES_DRAFT}${id}`);
|
||||
if (!storedValue) {
|
||||
return { fileIds: [], pendingPastes: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const storedDraft = JSON.parse(storedValue) as string[] | StoredFilesDraft;
|
||||
if (Array.isArray(storedDraft)) {
|
||||
return { fileIds: storedDraft, pendingPastes: {} };
|
||||
}
|
||||
|
||||
const pendingPastes = Object.fromEntries(
|
||||
Object.entries(storedDraft.pendingPastes ?? {}).map(
|
||||
([fileId, pendingPaste]): [string, PendingTextAttachmentDraft] => [
|
||||
fileId,
|
||||
{
|
||||
text: decodeBase64(pendingPaste.encodedText),
|
||||
selectionStart: pendingPaste.selectionStart,
|
||||
...(pendingPaste.selectionEnd != null
|
||||
? { selectionEnd: pendingPaste.selectionEnd }
|
||||
: {}),
|
||||
...(pendingPaste.encodedReplacedText
|
||||
? { replacedText: decodeBase64(pendingPaste.encodedReplacedText) }
|
||||
: {}),
|
||||
...(pendingPaste.sequence != null ? { sequence: pendingPaste.sequence } : {}),
|
||||
...(pendingPaste.replacedApplied ? { replacedApplied: true } : {}),
|
||||
...(pendingPaste.encodedAnchorBefore != null
|
||||
? { anchorBefore: decodeBase64(pendingPaste.encodedAnchorBefore) }
|
||||
: {}),
|
||||
...(pendingPaste.encodedAnchorAfter != null
|
||||
? { anchorAfter: decodeBase64(pendingPaste.encodedAnchorAfter) }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
fileIds: Array.isArray(storedDraft.fileIds) ? storedDraft.fileIds : [],
|
||||
pendingPastes,
|
||||
};
|
||||
} catch {
|
||||
return { fileIds: [], pendingPastes: {} };
|
||||
}
|
||||
};
|
||||
|
||||
export const setFilesDraft = (id: string, draft: FilesDraft): void => {
|
||||
const key = `${LocalStorageKeys.FILES_DRAFT}${id}`;
|
||||
const pendingPasteEntries = Object.entries(draft.pendingPastes);
|
||||
if (draft.fileIds.length === 0 && pendingPasteEntries.length === 0) {
|
||||
removeLocalStorageItem(key);
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${id}`);
|
||||
|
||||
if (pendingPasteEntries.length === 0) {
|
||||
setLocalStorageItem(key, JSON.stringify(draft.fileIds));
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingPastes = Object.fromEntries(
|
||||
pendingPasteEntries.map(
|
||||
([fileId, pendingPaste]): [string, StoredPendingTextAttachmentDraft] => [
|
||||
fileId,
|
||||
{
|
||||
encodedText: encodeBase64(pendingPaste.text),
|
||||
selectionStart: pendingPaste.selectionStart,
|
||||
...(pendingPaste.selectionEnd != null &&
|
||||
pendingPaste.selectionEnd !== pendingPaste.selectionStart
|
||||
? { selectionEnd: pendingPaste.selectionEnd }
|
||||
: {}),
|
||||
...(pendingPaste.replacedText
|
||||
? { encodedReplacedText: encodeBase64(pendingPaste.replacedText) }
|
||||
: {}),
|
||||
...(pendingPaste.sequence != null ? { sequence: pendingPaste.sequence } : {}),
|
||||
...(pendingPaste.replacedApplied ? { replacedApplied: true } : {}),
|
||||
...(pendingPaste.anchorBefore != null
|
||||
? { encodedAnchorBefore: encodeBase64(pendingPaste.anchorBefore) }
|
||||
: {}),
|
||||
...(pendingPaste.anchorAfter != null
|
||||
? { encodedAnchorAfter: encodeBase64(pendingPaste.anchorAfter) }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
setLocalStorageItem(
|
||||
key,
|
||||
JSON.stringify({ fileIds: draft.fileIds, pendingPastes } satisfies StoredFilesDraft),
|
||||
);
|
||||
};
|
||||
|
||||
/** Moves a text draft between keys, reporting whether there was one to move. */
|
||||
export const migrateTextDraft = (fromId: string, toId: string): boolean => {
|
||||
const key = `${LocalStorageKeys.TEXT_DRAFT}${fromId}`;
|
||||
const draftText = getLocalStorageItem(key);
|
||||
removeLocalStorageItem(key);
|
||||
if (!draftText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setLocalStorageItem(`${LocalStorageKeys.TEXT_DRAFT}${toId}`, draftText);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves a files draft between keys without ever holding two copies: a pending long paste can be
|
||||
* most of the storage budget on its own, so writing the destination while the source still exists
|
||||
* is what trips quota. Returns the id the record ended up under, so a caller that failed to move
|
||||
* it can still recover from the key that kept it.
|
||||
*/
|
||||
export const migrateFilesDraft = (fromId: string, toId: string): string => {
|
||||
const key = `${LocalStorageKeys.FILES_DRAFT}${fromId}`;
|
||||
const record = getLocalStorageItem(key);
|
||||
if (!record) {
|
||||
return toId;
|
||||
}
|
||||
|
||||
removeLocalStorageItem(key);
|
||||
try {
|
||||
localStorage.setItem(`${LocalStorageKeys.FILES_DRAFT}${toId}`, record);
|
||||
return toId;
|
||||
} catch {
|
||||
/** Storage cannot hold the record even with the source freed, so put it back rather than
|
||||
* dropping attachments that recovery can still read from the key it came from. */
|
||||
setLocalStorageItem(key, record);
|
||||
return fromId;
|
||||
}
|
||||
};
|
||||
|
||||
export const setPendingTextAttachmentDraft = ({
|
||||
id,
|
||||
fileId,
|
||||
text,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
replacedText,
|
||||
replacedApplied,
|
||||
anchorBefore,
|
||||
anchorAfter,
|
||||
}: {
|
||||
id: string;
|
||||
fileId: string;
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd?: number;
|
||||
replacedText?: string;
|
||||
replacedApplied?: boolean;
|
||||
anchorBefore?: string;
|
||||
anchorAfter?: string;
|
||||
}): void => {
|
||||
const draft = getFilesDraft(id);
|
||||
const existing = draft.pendingPastes[fileId];
|
||||
const sequence =
|
||||
existing?.sequence ??
|
||||
Math.max(0, ...Object.values(draft.pendingPastes).map((paste) => paste.sequence ?? 0)) + 1;
|
||||
setFilesDraft(id, {
|
||||
fileIds: draft.fileIds.includes(fileId) ? draft.fileIds : [...draft.fileIds, fileId],
|
||||
pendingPastes: {
|
||||
...draft.pendingPastes,
|
||||
[fileId]: {
|
||||
text,
|
||||
selectionStart,
|
||||
sequence,
|
||||
...(selectionEnd != null ? { selectionEnd } : {}),
|
||||
...(replacedText ? { replacedText } : {}),
|
||||
...(replacedApplied ? { replacedApplied: true } : {}),
|
||||
...(anchorBefore != null ? { anchorBefore } : {}),
|
||||
...(anchorAfter != null ? { anchorAfter } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const removePendingTextAttachmentDraft = ({
|
||||
id,
|
||||
fileId,
|
||||
removeFile = false,
|
||||
}: {
|
||||
id: string;
|
||||
fileId: string;
|
||||
removeFile?: boolean;
|
||||
}): void => {
|
||||
const draft = getFilesDraft(id);
|
||||
const pendingPastes = { ...draft.pendingPastes };
|
||||
delete pendingPastes[fileId];
|
||||
setFilesDraft(id, {
|
||||
fileIds: removeFile
|
||||
? draft.fileIds.filter((draftFileId) => draftFileId !== fileId)
|
||||
: draft.fileIds,
|
||||
pendingPastes,
|
||||
});
|
||||
};
|
||||
|
||||
export const setDraft = ({
|
||||
id,
|
||||
value,
|
||||
persistExact = false,
|
||||
}: {
|
||||
id: string;
|
||||
value?: string;
|
||||
persistExact?: boolean;
|
||||
}) => {
|
||||
const shouldPersist = persistExact
|
||||
? value != null && value.length > 0
|
||||
: value && value.length > 1;
|
||||
if (shouldPersist) {
|
||||
setLocalStorageItem(`${LocalStorageKeys.TEXT_DRAFT}${id}`, encodeBase64(value ?? ''));
|
||||
return;
|
||||
}
|
||||
removeLocalStorageItem(`${LocalStorageKeys.TEXT_DRAFT}${id}`);
|
||||
};
|
||||
|
||||
export const getDraft = (id?: string): string | null =>
|
||||
decodeBase64((localStorage.getItem(`${LocalStorageKeys.TEXT_DRAFT}${id ?? ''}`) ?? '') || '');
|
||||
decodeBase64((getLocalStorageItem(`${LocalStorageKeys.TEXT_DRAFT}${id ?? ''}`) ?? '') || '');
|
||||
|
||||
/**
|
||||
* Draft-key prefix for a live `ask_user_question` answer phase. While the
|
||||
|
|
|
|||
|
|
@ -486,6 +486,81 @@ export const getViableUploadOptions = (
|
|||
return options;
|
||||
};
|
||||
|
||||
/**
|
||||
* Character count past which a plain-text paste is attached as a file rather than inserted
|
||||
* into the composer. Roughly a screenful of prose, so ordinary pastes are untouched.
|
||||
*/
|
||||
export const PASTE_AS_FILE_MIN_LENGTH = 2500;
|
||||
|
||||
export const PASTED_TEXT_FILENAME = 'pasted-text.txt';
|
||||
|
||||
export type PasteAsFileContext = {
|
||||
/** The user's `pasteLongTextAsFile` preference. */
|
||||
enabled: boolean;
|
||||
uploadsDisabled: boolean;
|
||||
isAssistants: boolean;
|
||||
/** Names already attached to the composer, used to keep successive pastes distinct. */
|
||||
attachedFilenames: Set<string>;
|
||||
/** The file config the destination check reads has not arrived yet. */
|
||||
configPending: boolean;
|
||||
getOptions: (files: File[]) => (EToolResources | undefined)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads are deduped on name + size + type, so a fixed name would collapse that key to size
|
||||
* alone for pastes and reject a second, different paste that merely matched the first one's
|
||||
* length. Numbering keeps every paste attachable while staying readable in the UI.
|
||||
*/
|
||||
const nextPastedTextFilename = (taken: Set<string>): string => {
|
||||
let candidate = PASTED_TEXT_FILENAME;
|
||||
let suffix = 1;
|
||||
while (taken.has(candidate)) {
|
||||
suffix += 1;
|
||||
candidate = `pasted-text-${suffix}.txt`;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
export type PastedTextAttachment = {
|
||||
file: File;
|
||||
/** Context for non-assistant attachments; assistants resolve their destination on upload. */
|
||||
toolResource?: EToolResources;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a long plain-text paste into a text attachment, keeping the composer readable while
|
||||
* preserving the exact paste in the generated file. Context attachments follow the same
|
||||
* configured token limits as other uploaded text files. Returns `null` whenever the paste
|
||||
* should stay inline, so the caller can leave the browser's native paste untouched.
|
||||
*/
|
||||
export const resolvePastedTextFile = (
|
||||
text: string,
|
||||
ctx: PasteAsFileContext,
|
||||
): PastedTextAttachment | null => {
|
||||
if (!ctx.enabled || ctx.uploadsDisabled || text.length <= PASTE_AS_FILE_MIN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = nextPastedTextFilename(ctx.attachedFilenames);
|
||||
const file = new File([text], name, { type: 'text/plain' });
|
||||
if (ctx.isAssistants) {
|
||||
return { file };
|
||||
}
|
||||
|
||||
/** `context` is the only automatic non-assistant destination because retrieval-based routes
|
||||
* can change what the model sees. Pasting text must never pop a destination picker.
|
||||
*
|
||||
* That check reads MIME lists that arrive with the file config, so declining while the config
|
||||
* is still in flight would quietly ignore the setting on a slow first load. Routing the paste
|
||||
* instead hands the decision to the upload, which waits for the same config and restores the
|
||||
* text inline if it turns out the destination is unavailable. */
|
||||
if (!ctx.configPending && !ctx.getOptions([file]).includes(EToolResources.context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { file, toolResource: EToolResources.context };
|
||||
};
|
||||
|
||||
export function sortPagesByRelevance(
|
||||
pages: number[],
|
||||
pageRelevance: Record<number, number>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue