mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: composer review findings across dictation, uploads and steers
Dictation only ever submitted through the speech engines' auto-send callback, which they never fire with the default setting: stop-and-send left the transcript sitting in the composer, and a plain stop stopped honouring Auto Send Text at all. The send is now armed on the stop and spent once the take settles, with a per-take guard so the setting and the button cannot both spend it. Cancel also keeps the draft on its ref until a take is really spent, so an external transcription that was already in flight can no longer wipe the draft it just restored. Assistants get their unfiltered picker back — the provider check does not recognise them, so the palette had scoped them to images and dropped PDF support. Memory honours the user's personalization opt-out. Quote chips are gated on the same flag as the quote button, so an endpoint that cannot transmit an excerpt no longer shows one as staged. The upload-file shortcut targets the palette disclosure that replaced the attach menu, and Open Files opens the file manager dialog now that the side panel link is gone. Retried steers resolve "is the run over" from the conversation's submitting state instead of the block's unmount: navigating away from a live run unmounts it too, and queueing there sent the same words twice. Composer hints follow the Enter-to-send setting rather than always naming Enter as the send key.
This commit is contained in:
parent
36cda797ed
commit
2c924bf11a
18 changed files with 458 additions and 71 deletions
|
|
@ -411,7 +411,7 @@ const ChatForm = memo(function ChatForm({
|
|||
|
||||
const isMoreThanThreeRows = visualRowCount > 3;
|
||||
|
||||
const composerItems = useComposerItems(conversationId);
|
||||
const composerItems = useComposerItems(conversationId, quotesEnabled);
|
||||
const attachTarget = useAttachTarget(conversation, disableInputs);
|
||||
const dictation = useDictation({ ask: submitMessage, methods, isSubmitting });
|
||||
const uploadingCount = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ export const COMPOSER_HINT_ID = 'composer-hint';
|
|||
* here would re-announce on every keystroke as the hint flips between idle and
|
||||
* typing, so the description channel carries it instead.
|
||||
*/
|
||||
function Hints(state: ComposerHintState) {
|
||||
const hint = useComposerHint(state);
|
||||
function Hints(state: Omit<ComposerHintState, 'enterToSend'>) {
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const hint = useComposerHint({ ...state, enterToSend });
|
||||
const showTips = useRecoilValue(store.showComposerTips);
|
||||
const visible = showTips || hint.kind === 'state';
|
||||
|
||||
|
|
|
|||
|
|
@ -896,6 +896,9 @@ function Palette({
|
|||
disclosure's own click handler and the popover never opened. */}
|
||||
<Ariakit.PopoverDisclosure
|
||||
ref={disclosureRef}
|
||||
/* The upload-file shortcut resolves its target by id, and this is
|
||||
the control that replaced the attach menu it used to click. */
|
||||
id="attach-file-menu-button"
|
||||
disabled={disabled}
|
||||
aria-label={dictating ? localize('com_ui_cancel') : localize('com_ui_composer_palette')}
|
||||
/* Kept as the disclosure while dictating rather than swapped for a
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, memo, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
|
||||
import {
|
||||
Archive,
|
||||
|
|
@ -100,7 +100,7 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
enabled: !!isAuthenticated && startupConfig?.balance?.enabled,
|
||||
});
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const [showFiles, setShowFiles] = useRecoilState(store.showFilesDialog);
|
||||
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { act, render, renderHook } from '@testing-library/react';
|
||||
import { RecoilRoot, useRecoilValue, type MutableSnapshot } from 'recoil';
|
||||
import { RecoilRoot, useRecoilValue, useSetRecoilState, type MutableSnapshot } from 'recoil';
|
||||
import useSteerRecovery from '../useSteerRecovery';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -16,9 +16,25 @@ const flush = () => act(async () => undefined);
|
|||
|
||||
const CONVO_ID = 'convo-steer-recovery';
|
||||
|
||||
/** The hook resolves "is the run over" from the conversation's own submitting
|
||||
* state, not from its own unmount, so every case has to place the
|
||||
* conversation in the store the way `ChatView` does. */
|
||||
const seedRun = (snapshot: MutableSnapshot, submitting: boolean) => {
|
||||
snapshot.set(store.conversationKeysAtom, [0]);
|
||||
snapshot.set(store.conversationByIndex(0), { conversationId: CONVO_ID } as never);
|
||||
snapshot.set(store.isSubmittingFamily(0), submitting);
|
||||
};
|
||||
|
||||
function setup(initialize?: (snapshot: MutableSnapshot) => void) {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot initializeState={initialize}>{children}</RecoilRoot>
|
||||
<RecoilRoot
|
||||
initializeState={(snapshot) => {
|
||||
seedRun(snapshot, true);
|
||||
initialize?.(snapshot);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecoilRoot>
|
||||
);
|
||||
return renderHook(
|
||||
() => ({
|
||||
|
|
@ -125,17 +141,15 @@ describe('useSteerRecovery', () => {
|
|||
expect(result.current.queue).toEqual([]);
|
||||
});
|
||||
|
||||
/* The block this hook lives in unmounts the moment the run ends, which is
|
||||
exactly when a retry's ack tends to land. It has to survive that: the
|
||||
words go to the queue rather than leaving the chip saying `sending`. */
|
||||
/* The block this hook lives in unmounts the moment the run ends, which is
|
||||
exactly when a retry's ack tends to land. It has to survive that: the
|
||||
words go to the queue rather than leaving the chip saying `sending`. */
|
||||
it('queues a retry whose ack lands after the run ended', async () => {
|
||||
/* A retry's ack tends to land right as the run ends, which is also when
|
||||
the block this hook lives in unmounts. Whether the words go to the queue
|
||||
turns on the run, not on the unmount. */
|
||||
const lateAck = async (endRun: boolean) => {
|
||||
let settle: (value: unknown) => void = () => undefined;
|
||||
mockMutateAsync.mockReturnValue(new Promise((resolve) => (settle = resolve)));
|
||||
|
||||
let recovery: ReturnType<typeof useSteerRecovery> | undefined;
|
||||
let setSubmitting: ((value: boolean) => void) | undefined;
|
||||
let chips: unknown[] = [];
|
||||
let queue: unknown[] = [];
|
||||
const Recovery = () => {
|
||||
|
|
@ -147,12 +161,14 @@ describe('useSteerRecovery', () => {
|
|||
const Observer = () => {
|
||||
chips = useRecoilValue(store.pendingSteersByConvoId(CONVO_ID));
|
||||
queue = useRecoilValue(store.queuedMessagesByConvoId(CONVO_ID));
|
||||
setSubmitting = useSetRecoilState(store.isSubmittingFamily(0));
|
||||
return null;
|
||||
};
|
||||
const Tree = ({ live }: { live: boolean }) => (
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) => {
|
||||
set(store.pendingSteersByConvoId(CONVO_ID), [
|
||||
initializeState={(snapshot) => {
|
||||
seedRun(snapshot, true);
|
||||
snapshot.set(store.pendingSteersByConvoId(CONVO_ID), [
|
||||
{ steerId: 'local-late', text: 'landed too late', status: 'failed', createdAt: 3 },
|
||||
]);
|
||||
}}
|
||||
|
|
@ -168,15 +184,32 @@ describe('useSteerRecovery', () => {
|
|||
});
|
||||
expect(chips).toEqual([expect.objectContaining({ status: 'sending' })]);
|
||||
|
||||
if (endRun) {
|
||||
act(() => setSubmitting?.(false));
|
||||
}
|
||||
rerender(<Tree live={false} />);
|
||||
await act(async () => {
|
||||
settle({ steerId: 'srv-late', status: 'queued', position: 1, conversationId: CONVO_ID });
|
||||
});
|
||||
|
||||
return { chips, queue };
|
||||
};
|
||||
|
||||
it('queues a retry whose ack lands after the run ended', async () => {
|
||||
const { chips, queue } = await lateAck(true);
|
||||
expect(chips).toEqual([]);
|
||||
expect(queue).toEqual([expect.objectContaining({ id: 'srv-late', text: 'landed too late' })]);
|
||||
});
|
||||
|
||||
/* Navigating away unmounts the same block while the resumable run carries
|
||||
on, and the server will still inject the accepted steer. Queueing it as
|
||||
a follow-up here is what sent the same words twice. */
|
||||
it('leaves the ack pending when the block unmounts on a still-live run', async () => {
|
||||
const { chips, queue } = await lateAck(false);
|
||||
expect(chips).toEqual([expect.objectContaining({ steerId: 'srv-late', status: 'pending' })]);
|
||||
expect(queue).toEqual([]);
|
||||
});
|
||||
|
||||
/* The picks the message was written with have to survive the retry, or the
|
||||
words are re-sent without the quotes and skills they referred to. */
|
||||
it('carries the quotes and skills the steer was written with', async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import type { Snapshot } from 'recoil';
|
||||
import type { PendingSteer } from '~/store/families';
|
||||
import { getSteerErrorCode, resolveAcknowledgedSteer } from '~/hooks/Chat/useSteering';
|
||||
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
|
||||
|
|
@ -16,16 +17,24 @@ export default function useSteerRecovery(conversationId: string) {
|
|||
const { mutateAsync: steerMessage } = useSteerMessageMutation();
|
||||
const convertSteersToQueued = useSteerConvert();
|
||||
|
||||
/** `PendingSteers` only renders while `isLast && isSubmitting` is true, so
|
||||
* its unmount IS the run ending. A retry's ack can resolve afterward;
|
||||
* mirrors `composerMountedRef` in `ChatForm`, which guards the equivalent
|
||||
* race for a reclaimed steer whose restore resolves post-unmount. */
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
/** Whether the run this steer belongs to has finished, read live at ack time.
|
||||
* Unmounting is not the same signal: `PendingSteers` also unmounts when the
|
||||
* user navigates away from a run that is still going, and treating that as
|
||||
* the end queued the accepted steer as a follow-up on top of the injection
|
||||
* the server was already making. */
|
||||
const isRunOver = useCallback(
|
||||
(snapshot: Snapshot) => {
|
||||
const keys = snapshot.getLoadable(store.conversationKeysAtom).getValue();
|
||||
for (const key of keys) {
|
||||
const convo = snapshot.getLoadable(store.conversationByIndex(key)).getValue();
|
||||
if (convo?.conversationId !== conversationId) {
|
||||
continue;
|
||||
}
|
||||
return snapshot.getLoadable(store.isSubmittingFamily(key)).getValue() !== true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const markStatus = useRecoilCallback(
|
||||
|
|
@ -39,10 +48,16 @@ export default function useSteerRecovery(conversationId: string) {
|
|||
);
|
||||
|
||||
const acknowledgeRetry = useRecoilCallback(
|
||||
(cbInterface) => (localId: string, steer: PendingSteer, runOver: boolean) => {
|
||||
resolveAcknowledgedSteer(cbInterface, conversationId, localId, steer, runOver);
|
||||
(cbInterface) => (localId: string, steer: PendingSteer) => {
|
||||
resolveAcknowledgedSteer(
|
||||
cbInterface,
|
||||
conversationId,
|
||||
localId,
|
||||
steer,
|
||||
isRunOver(cbInterface.snapshot),
|
||||
);
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, isRunOver],
|
||||
);
|
||||
|
||||
/** Routes a steer straight into the queue: reused for a retry that degrades
|
||||
|
|
@ -84,11 +99,7 @@ export default function useSteerRecovery(conversationId: string) {
|
|||
rest of the conversation with the words neither sent nor queued. */
|
||||
steerMessage({ conversationId, text: steer.text, files: steer.files })
|
||||
.then((response) => {
|
||||
acknowledgeRetry(
|
||||
steerId,
|
||||
{ ...steer, steerId: response.steerId, status: 'pending' },
|
||||
!mountedRef.current,
|
||||
);
|
||||
acknowledgeRetry(steerId, { ...steer, steerId: response.steerId, status: 'pending' });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const code = getSteerErrorCode(error);
|
||||
|
|
|
|||
|
|
@ -186,6 +186,22 @@ describe('useAttachItems', () => {
|
|||
).toContain('local:image');
|
||||
});
|
||||
|
||||
/* Assistants carry their own file configuration and the old menu handed
|
||||
them an unfiltered picker; scoping them to the image capability, which
|
||||
the provider check would, silently dropped PDF support. */
|
||||
it.each([EModelEndpoint.assistants, EModelEndpoint.azureAssistants])(
|
||||
'offers one unfiltered upload for %s and no tool destinations',
|
||||
(endpoint) => {
|
||||
const ids = renderEntries({
|
||||
endpoint,
|
||||
contextEnabled: true,
|
||||
fileSearchEnabled: true,
|
||||
codeEnabled: true,
|
||||
});
|
||||
expect(ids).toEqual(['local:assistants']);
|
||||
},
|
||||
);
|
||||
|
||||
it('reads a provider whatever its casing, which OpenRouter arrives in', () => {
|
||||
expect(renderEntries({ provider: 'OpenRouter' })).toContain('local:provider');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const baseState: ComposerHintState = {
|
|||
duringRunAction: 'queue' as const,
|
||||
answerModeActive: false,
|
||||
uploadingCount: 0,
|
||||
enterToSend: true,
|
||||
};
|
||||
|
||||
const hint = (overrides: Partial<ComposerHintState>, isMac = true) =>
|
||||
|
|
@ -68,6 +69,29 @@ describe('composeHint', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('with Enter bound to a newline', () => {
|
||||
it('names the chord as the send key while typing', () => {
|
||||
const result = hint({ hasText: true, enterToSend: false });
|
||||
expect(result).toContain('⌘⏎');
|
||||
expect(result).toContain('com_ui_composer_hint_send');
|
||||
expect(result).toContain('com_ui_composer_hint_newline');
|
||||
expect(result).not.toContain('com_ui_composer_hint_typing');
|
||||
});
|
||||
|
||||
it('drops the alternate during-run action, which the chord no longer reaches', () => {
|
||||
const result = hint({
|
||||
duringRunActive: true,
|
||||
hasText: true,
|
||||
isSubmitting: true,
|
||||
duringRunAction: 'steer',
|
||||
enterToSend: false,
|
||||
});
|
||||
expect(result).toContain('⌘⏎ com_ui_composer_hint_steer_verb');
|
||||
expect(result).toContain('com_ui_composer_hint_interrupt');
|
||||
expect(result).not.toContain('com_ui_composer_hint_queue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kind', () => {
|
||||
it('marks the ambient copy as a tip, so a conversation can drop it', () => {
|
||||
expect(kindOf({})).toBe('tip');
|
||||
|
|
|
|||
180
client/src/hooks/Input/__tests__/useDictation.spec.tsx
Normal file
180
client/src/hooks/Input/__tests__/useDictation.spec.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { TAskFunction } from '~/common';
|
||||
import useDictation from '../useDictation';
|
||||
import store from '~/store';
|
||||
|
||||
/**
|
||||
* The engines only report a completed transcription when Auto Send Text is
|
||||
* configured, so the composer's own stop-and-send cannot be built on that
|
||||
* callback alone. These cases pin the three ways a take can be spent — sent on
|
||||
* request, sent by the setting, or thrown away — against both engines.
|
||||
*/
|
||||
|
||||
let mockSpeechEndpoint: 'browser' | 'external';
|
||||
let mockSetTextCallback: (text: string) => void;
|
||||
let mockOnTranscriptionComplete: (text: string) => void;
|
||||
const mockStart = jest.fn();
|
||||
const mockStop = jest.fn();
|
||||
const mockAbort = jest.fn();
|
||||
let mockIsListening: boolean;
|
||||
let mockIsLoading: boolean;
|
||||
|
||||
jest.mock('../useSpeechToText', () => ({
|
||||
__esModule: true,
|
||||
default: (setText: (text: string) => void, complete: (text: string) => void) => {
|
||||
mockSetTextCallback = setText;
|
||||
mockOnTranscriptionComplete = complete;
|
||||
return {
|
||||
isListening: mockIsListening,
|
||||
isLoading: mockIsLoading,
|
||||
startRecording: mockStart,
|
||||
stopRecording: mockStop,
|
||||
abortRecording: mockAbort,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../useGetAudioSettings', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({ speechToTextEndpoint: mockSpeechEndpoint }),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('../../useLocalize', () => ({
|
||||
__esModule: true,
|
||||
default: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const ask = jest.fn(() => true) as unknown as jest.Mock & TAskFunction;
|
||||
|
||||
function setup({ autoSendText = -1, draft = '' }: { autoSendText?: number; draft?: string } = {}) {
|
||||
let text = draft;
|
||||
const methods = {
|
||||
setValue: jest.fn((_name: string, value: string) => {
|
||||
text = value;
|
||||
}),
|
||||
reset: jest.fn((values: { text: string }) => {
|
||||
text = values.text;
|
||||
}),
|
||||
getValues: jest.fn(() => text),
|
||||
};
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot initializeState={({ set }) => set(store.autoSendText, autoSendText)}>
|
||||
{children}
|
||||
</RecoilRoot>
|
||||
);
|
||||
const view = renderHook(
|
||||
() =>
|
||||
useDictation({
|
||||
ask: ask as unknown as TAskFunction,
|
||||
methods: methods as never,
|
||||
isSubmitting: false,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
return { ...view, methods, currentText: () => text };
|
||||
}
|
||||
|
||||
/** Ends the take the way an engine does: listening clears, then the settle
|
||||
* backstop expires. */
|
||||
const settle = async (rerender: () => void) => {
|
||||
mockIsListening = false;
|
||||
act(() => {
|
||||
rerender();
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
};
|
||||
|
||||
describe('useDictation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
mockSpeechEndpoint = 'browser';
|
||||
mockIsListening = false;
|
||||
mockIsLoading = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('sends on stop-and-send even with Auto Send Text off, which reports no transcription', async () => {
|
||||
const { result, rerender, currentText } = setup();
|
||||
act(() => result.current.start());
|
||||
mockIsListening = true;
|
||||
act(() => rerender());
|
||||
|
||||
act(() => mockSetTextCallback('take me somewhere'));
|
||||
act(() => result.current.stopAndSend());
|
||||
await settle(rerender);
|
||||
|
||||
expect(ask).toHaveBeenCalledWith({ text: 'take me somewhere' });
|
||||
expect(currentText()).toBe('');
|
||||
});
|
||||
|
||||
it('leaves a plain stop in the composer', async () => {
|
||||
const { result, rerender, currentText } = setup();
|
||||
act(() => result.current.start());
|
||||
mockIsListening = true;
|
||||
act(() => rerender());
|
||||
|
||||
act(() => mockSetTextCallback('just a draft'));
|
||||
act(() => result.current.stopToComposer());
|
||||
await settle(rerender);
|
||||
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
expect(currentText()).toBe('just a draft');
|
||||
});
|
||||
|
||||
it('still honours Auto Send Text on a plain stop', async () => {
|
||||
const { result, rerender } = setup({ autoSendText: 0 });
|
||||
act(() => result.current.start());
|
||||
mockIsListening = true;
|
||||
act(() => rerender());
|
||||
|
||||
act(() => result.current.stopToComposer());
|
||||
await settle(rerender);
|
||||
act(() => mockOnTranscriptionComplete('send this for me'));
|
||||
|
||||
expect(ask).toHaveBeenCalledWith({ text: 'send this for me' });
|
||||
});
|
||||
|
||||
it('spends a take once when the setting and the send button both reach it', async () => {
|
||||
const { result, rerender } = setup({ autoSendText: 0 });
|
||||
act(() => result.current.start());
|
||||
mockIsListening = true;
|
||||
act(() => rerender());
|
||||
|
||||
act(() => mockSetTextCallback('only once'));
|
||||
act(() => result.current.stopAndSend());
|
||||
await settle(rerender);
|
||||
act(() => mockOnTranscriptionComplete('only once'));
|
||||
|
||||
expect(ask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the draft when a cancelled external transcription lands anyway', async () => {
|
||||
mockSpeechEndpoint = 'external';
|
||||
const { result, rerender, currentText } = setup({ draft: 'my unsent draft' });
|
||||
act(() => result.current.start());
|
||||
mockIsListening = true;
|
||||
act(() => rerender());
|
||||
|
||||
act(() => result.current.cancel());
|
||||
expect(currentText()).toBe('my unsent draft');
|
||||
|
||||
/* The mutation was already in flight, so aborting the recorder cannot stop
|
||||
its success callback from arriving. */
|
||||
act(() => mockOnTranscriptionComplete('words nobody asked for'));
|
||||
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
expect(currentText()).toBe('my unsent draft');
|
||||
});
|
||||
});
|
||||
|
|
@ -18,11 +18,13 @@ let mockContext: Record<string, unknown> | null;
|
|||
let mockSkills: Array<Record<string, unknown>>;
|
||||
let mockAgentsMap: Record<string, Record<string, unknown>>;
|
||||
let mockSkillsActive: boolean;
|
||||
let mockUser: { personalization?: { memories?: boolean } } | undefined;
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useHasAccess: ({ permissionType }: { permissionType: string }) =>
|
||||
mockPermissions[permissionType] ?? false,
|
||||
useHasMemoryAccess: () => mockMemoryAccess,
|
||||
useAuthContext: () => ({ user: mockUser }),
|
||||
useAgentCapabilities: () => mockCapabilities,
|
||||
/* The real popover filter is used here, and it drops anything the user
|
||||
cannot invoke or that is not active for them. */
|
||||
|
|
@ -125,6 +127,7 @@ describe('usePaletteEntries', () => {
|
|||
beforeEach(() => {
|
||||
mockPermissions = { ...allPermissions };
|
||||
mockMemoryAccess = true;
|
||||
mockUser = { personalization: { memories: true } };
|
||||
mockCapabilities = { ...allCapabilities };
|
||||
mockContext = fullContext();
|
||||
mockSkills = [];
|
||||
|
|
@ -178,6 +181,11 @@ describe('usePaletteEntries', () => {
|
|||
mockMemoryAccess = false;
|
||||
expect(keysOf(entries().result)).not.toContain('builtin:memory');
|
||||
});
|
||||
|
||||
it('withholds memory from a user who opted out in personalization', () => {
|
||||
mockUser = { personalization: { memories: false } };
|
||||
expect(keysOf(entries().result)).not.toContain('builtin:memory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('on state', () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
EModelEndpoint,
|
||||
getConfiguredMimeAccept,
|
||||
bedrockDocumentMimeTypes,
|
||||
isAssistantsEndpoint,
|
||||
defaultAgentCapabilities,
|
||||
bedrockDocumentExtensions,
|
||||
isDocumentSupportedProvider,
|
||||
|
|
@ -201,6 +202,25 @@ export default function useAttachItems({
|
|||
|
||||
const build = (onAction: (fileType?: FileUploadType) => void, prefix: string) => {
|
||||
const items: AttachEntry[] = [];
|
||||
|
||||
/* Assistants own their own file handling: whatever the assistant is
|
||||
configured to accept goes up unfiltered, and none of the tool
|
||||
destinations below apply. Scoping this to the image capability, as the
|
||||
provider check would, silently dropped PDF support. */
|
||||
if (isAssistantsEndpoint(endpoint)) {
|
||||
items.push({
|
||||
id: `${prefix}:assistants`,
|
||||
label: localize('com_sidepanel_attach_files'),
|
||||
primary: true,
|
||||
icon: <FileImageIcon className="icon-md" aria-hidden="true" />,
|
||||
onSelect: () => {
|
||||
setToolResource(undefined);
|
||||
onAction();
|
||||
},
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
let currentProvider = provider || endpoint;
|
||||
|
||||
// This will be removed in a future PR to formally normalize Providers comparisons to be case insensitive
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export interface ComposerHintState {
|
|||
/** The composer is the answer box for a paused `ask_user_question`. */
|
||||
answerModeActive: boolean;
|
||||
uploadingCount: number;
|
||||
/** Plain Enter submits. When off, Enter inserts a newline and the modifier
|
||||
* chord is what submits, which inverts every shortcut named below. */
|
||||
enterToSend: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -62,16 +65,20 @@ export function composeHint(
|
|||
if (state.duringRunActive && state.hasText) {
|
||||
const mod = isMac ? '⌘⏎' : 'Ctrl+⏎';
|
||||
const alt = isMac ? '⌥⏎' : 'Alt+⏎';
|
||||
const parts =
|
||||
state.duringRunAction === 'steer'
|
||||
? [
|
||||
localize('com_ui_composer_hint_steer'),
|
||||
`${mod} ${localize('com_ui_composer_hint_queue')}`,
|
||||
]
|
||||
: [
|
||||
localize('com_ui_composer_hint_queue_default'),
|
||||
`${mod} ${localize('com_ui_composer_hint_send_now')}`,
|
||||
];
|
||||
const isSteer = state.duringRunAction === 'steer';
|
||||
/* With plain Enter bound to a newline, the chord IS the default action and
|
||||
there is no second chord left to reach the alternate one, so the hint
|
||||
names only what the composer will actually do. */
|
||||
const defaultParts = isSteer
|
||||
? [localize('com_ui_composer_hint_steer'), `${mod} ${localize('com_ui_composer_hint_queue')}`]
|
||||
: [
|
||||
localize('com_ui_composer_hint_queue_default'),
|
||||
`${mod} ${localize('com_ui_composer_hint_send_now')}`,
|
||||
];
|
||||
const chordVerb = isSteer
|
||||
? 'com_ui_composer_hint_steer_verb'
|
||||
: 'com_ui_composer_hint_queue_verb';
|
||||
const parts = state.enterToSend ? defaultParts : [`${mod} ${localize(chordVerb)}`];
|
||||
return {
|
||||
text: [...parts, `${alt} ${localize('com_ui_composer_hint_interrupt')}`].join(SEPARATOR),
|
||||
kind: 'state',
|
||||
|
|
@ -83,6 +90,16 @@ export function composeHint(
|
|||
}
|
||||
|
||||
if (state.hasText) {
|
||||
if (!state.enterToSend) {
|
||||
const mod = isMac ? '⌘⏎' : 'Ctrl+⏎';
|
||||
return {
|
||||
text: [
|
||||
`${mod} ${localize('com_ui_composer_hint_send')}`,
|
||||
`⏎ ${localize('com_ui_composer_hint_newline')}`,
|
||||
].join(SEPARATOR),
|
||||
kind: 'tip',
|
||||
};
|
||||
}
|
||||
return { text: localize('com_ui_composer_hint_typing'), kind: 'tip' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,15 @@ export interface ComposerItem {
|
|||
/**
|
||||
* Merges the staged-text sources into a single ordered list in one pass each,
|
||||
* so the tray renders one homogeneous list instead of a row per source.
|
||||
*
|
||||
* `includeQuotes` is false on the endpoints whose client path cannot transmit
|
||||
* an excerpt: showing a quote as staged there promises something the send
|
||||
* would silently drop.
|
||||
*/
|
||||
export default function useComposerItems(conversationId: string): ComposerItem[] {
|
||||
export default function useComposerItems(
|
||||
conversationId: string,
|
||||
includeQuotes = true,
|
||||
): ComposerItem[] {
|
||||
const quotes = useRecoilValue(store.pendingQuotesByConvoId(conversationId));
|
||||
const skills = useRecoilValue(store.pendingManualSkillsByConvoId(conversationId));
|
||||
const setQuotes = useSetRecoilState(store.pendingQuotesByConvoId(conversationId));
|
||||
|
|
@ -46,7 +53,7 @@ export default function useComposerItems(conversationId: string): ComposerItem[]
|
|||
identical excerpts: an index in every id rewrote the ids of everything
|
||||
after a removal, remounting those chips and dropping focus. */
|
||||
const seen = new Map<string, number>();
|
||||
for (let i = 0; i < quotes.length; i++) {
|
||||
for (let i = 0; includeQuotes && i < quotes.length; i++) {
|
||||
const text = quotes[i];
|
||||
const repeat = seen.get(text) ?? 0;
|
||||
seen.set(text, repeat + 1);
|
||||
|
|
@ -70,5 +77,5 @@ export default function useComposerItems(conversationId: string): ComposerItem[]
|
|||
}
|
||||
|
||||
return items;
|
||||
}, [quotes, skills, removeQuoteAt, removeSkill]);
|
||||
}, [quotes, skills, includeQuotes, removeQuoteAt, removeSkill]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import type { TAskFunction } from '~/common';
|
||||
import useGetAudioSettings from './useGetAudioSettings';
|
||||
|
|
@ -6,6 +7,7 @@ import { useChatFormContext } from '~/Providers';
|
|||
import useSpeechToText from './useSpeechToText';
|
||||
import { globalAudioId } from '~/common';
|
||||
import useLocalize from '../useLocalize';
|
||||
import store from '~/store';
|
||||
|
||||
const isExternalSTT = (speechToTextEndpoint: string) => speechToTextEndpoint === 'external';
|
||||
|
||||
|
|
@ -51,6 +53,10 @@ export default function useDictation({
|
|||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { speechToTextEndpoint } = useGetAudioSettings();
|
||||
const autoSendText = useRecoilValue(store.autoSendText);
|
||||
/** The Auto Send Text setting, which submits a plain recording once its
|
||||
* transcript settles. A stop that was never asked to send still honours it. */
|
||||
const autoSendEnabled = autoSendText > -1;
|
||||
|
||||
const existingTextRef = useRef<string>('');
|
||||
const isSubmittingRef = useRef(isSubmitting);
|
||||
|
|
@ -58,15 +64,49 @@ export default function useDictation({
|
|||
/** Read when the transcription lands, which is after the stop that set it.
|
||||
* The speech hooks have no notion of cancelling or of deferring a send. */
|
||||
const modeRef = useRef<StopMode>('compose');
|
||||
/** One submission per take. Both the settle fallback below and a late
|
||||
* auto-send callback can reach the same transcript, and whichever gets
|
||||
* there first is the one that spends it. */
|
||||
const spentRef = useRef(false);
|
||||
|
||||
const submit = useCallback(
|
||||
(text: string) => {
|
||||
if (spentRef.current) {
|
||||
return;
|
||||
}
|
||||
if (isSubmittingRef.current) {
|
||||
showToast({ message: localize('com_ui_speech_while_submitting'), status: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
spentRef.current = true;
|
||||
const globalAudio = document.getElementById(globalAudioId) as HTMLAudioElement | null;
|
||||
if (globalAudio) {
|
||||
globalAudio.muted = false;
|
||||
}
|
||||
const submitted = ask({ text });
|
||||
if (submitted === false) {
|
||||
spentRef.current = false;
|
||||
return;
|
||||
}
|
||||
reset({ text: '' });
|
||||
existingTextRef.current = '';
|
||||
},
|
||||
[ask, reset, showToast, localize],
|
||||
);
|
||||
|
||||
const onTranscriptionComplete = useCallback(
|
||||
(text: string) => {
|
||||
const mode = modeRef.current;
|
||||
modeRef.current = 'compose';
|
||||
|
||||
if (mode === 'cancel') {
|
||||
/* The draft stays on the ref until a take is actually spent: an
|
||||
external transcription already in flight cannot be recalled, and
|
||||
clearing it at cancel time left this reset wiping the very draft the
|
||||
cancel had just put back. */
|
||||
reset({ text: existingTextRef.current });
|
||||
existingTextRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +116,7 @@ export default function useDictation({
|
|||
? `${existingTextRef.current} ${text}`
|
||||
: text;
|
||||
|
||||
if (mode === 'compose') {
|
||||
if (mode === 'compose' && !autoSendEnabled) {
|
||||
if (finalText) {
|
||||
setValue('text', finalText, { shouldValidate: true });
|
||||
}
|
||||
|
|
@ -84,25 +124,9 @@ export default function useDictation({
|
|||
return;
|
||||
}
|
||||
|
||||
if (isSubmittingRef.current) {
|
||||
showToast({ message: localize('com_ui_speech_while_submitting'), status: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!finalText) {
|
||||
return;
|
||||
}
|
||||
const globalAudio = document.getElementById(globalAudioId) as HTMLAudioElement | null;
|
||||
if (globalAudio) {
|
||||
globalAudio.muted = false;
|
||||
}
|
||||
const submitted = ask({ text: finalText });
|
||||
if (submitted === false) {
|
||||
return;
|
||||
}
|
||||
reset({ text: '' });
|
||||
existingTextRef.current = '';
|
||||
submit(finalText);
|
||||
},
|
||||
[ask, reset, setValue, showToast, localize, speechToTextEndpoint],
|
||||
[reset, setValue, submit, autoSendEnabled, speechToTextEndpoint],
|
||||
);
|
||||
|
||||
const setText = useCallback(
|
||||
|
|
@ -155,8 +179,24 @@ export default function useDictation({
|
|||
return () => clearInterval(timer);
|
||||
}, [active]);
|
||||
|
||||
/* The engines only report a completed transcription when Auto Send Text is
|
||||
configured, so an explicit stop-and-send cannot wait for that callback:
|
||||
with the default setting it never arrives. Instead the send is armed here
|
||||
and spent once the take has fully settled, reading whatever the transcript
|
||||
put in the composer. */
|
||||
const [pendingSend, setPendingSend] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!pendingSend || active || isLoading === true || settling) {
|
||||
return;
|
||||
}
|
||||
setPendingSend(false);
|
||||
submit(getValues('text') || '');
|
||||
}, [pendingSend, active, isLoading, settling, submit, getValues]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
modeRef.current = 'compose';
|
||||
spentRef.current = false;
|
||||
setPendingSend(false);
|
||||
existingTextRef.current = getValues('text') || '';
|
||||
startRecording();
|
||||
}, [getValues, startRecording]);
|
||||
|
|
@ -165,6 +205,9 @@ export default function useDictation({
|
|||
(mode: StopMode) => {
|
||||
modeRef.current = mode;
|
||||
setSettling(true);
|
||||
if (mode === 'send') {
|
||||
setPendingSend(true);
|
||||
}
|
||||
stopRecording();
|
||||
},
|
||||
[stopRecording],
|
||||
|
|
@ -176,10 +219,11 @@ export default function useDictation({
|
|||
that was already in flight lands anyway. */
|
||||
const cancel = useCallback(() => {
|
||||
modeRef.current = 'cancel';
|
||||
spentRef.current = true;
|
||||
setSettling(false);
|
||||
setPendingSend(false);
|
||||
abortRecording();
|
||||
reset({ text: existingTextRef.current });
|
||||
existingTextRef.current = '';
|
||||
}, [abortRecording, reset]);
|
||||
|
||||
const stopToComposer = useCallback(() => stopWith('compose'), [stopWith]);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import type { TSkillSummary, TToolFavoriteType } from 'librechat-data-provider';
|
||||
import {
|
||||
useHasAccess,
|
||||
useAuthContext,
|
||||
useHasMemoryAccess,
|
||||
useAgentCapabilities,
|
||||
useSkillActiveState,
|
||||
|
|
@ -160,7 +161,11 @@ export default function usePaletteEntries({
|
|||
permissionType: PermissionTypes.SKILLS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const canUseMemory = useHasMemoryAccess();
|
||||
const { user } = useAuthContext();
|
||||
/* Personalization is the user's own opt-out and the backend refuses to
|
||||
register the memory tools once it is off, so role access and the global
|
||||
capability are not enough to offer the toggle. */
|
||||
const canUseMemory = useHasMemoryAccess() && user?.personalization?.memories !== false;
|
||||
|
||||
const skillsListable = enabled && canUseSkills && skillsEnabled;
|
||||
const allSkills = useAllSkills(skillsListable);
|
||||
|
|
|
|||
|
|
@ -434,6 +434,7 @@ export function useShortcutActions(): ShortcutAction[] {
|
|||
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
|
||||
const [sidebarExpanded, setSidebarExpanded] = useRecoilState(store.sidebarExpanded);
|
||||
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
|
||||
const setShowFilesDialog = useSetRecoilState(store.showFilesDialog);
|
||||
const setIsTemporary = useSetRecoilState(store.isTemporary);
|
||||
const setDeleteTarget = useSetRecoilState(store.keyboardDeleteTarget);
|
||||
const hasAccessToTemporaryChat = useHasAccess({
|
||||
|
|
@ -721,7 +722,12 @@ export function useShortcutActions(): ShortcutAction[] {
|
|||
const handleOpenPrompts = useCallback(() => handleOpenPanel('prompts'), [handleOpenPanel]);
|
||||
const handleOpenMemories = useCallback(() => handleOpenPanel('memories'), [handleOpenPanel]);
|
||||
const handleOpenParameters = useCallback(() => handleOpenPanel('parameters'), [handleOpenPanel]);
|
||||
const handleOpenFiles = useCallback(() => handleOpenPanel('files'), [handleOpenPanel]);
|
||||
/* The file manager moved out of the side panel and into a dialog, so there is
|
||||
no `nav-panel-files` button left for `handleOpenPanel` to find. */
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
setShowFilesDialog(true);
|
||||
return true;
|
||||
}, [setShowFilesDialog]);
|
||||
const handleOpenBookmarks = useCallback(() => handleOpenPanel('bookmarks'), [handleOpenPanel]);
|
||||
const handleOpenMCP = useCallback(() => handleOpenPanel('mcp-builder'), [handleOpenPanel]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1001,10 +1001,14 @@
|
|||
"com_ui_composer_hint_answer": "Answering. Esc to dismiss",
|
||||
"com_ui_composer_hint_idle": "/ for prompts · @ for models · + to attach",
|
||||
"com_ui_composer_hint_interrupt": "interrupt & send",
|
||||
"com_ui_composer_hint_newline": "for newline",
|
||||
"com_ui_composer_hint_queue": "queue",
|
||||
"com_ui_composer_hint_queue_default": "Enter queues",
|
||||
"com_ui_composer_hint_queue_verb": "queues",
|
||||
"com_ui_composer_hint_send": "to send",
|
||||
"com_ui_composer_hint_send_now": "send now",
|
||||
"com_ui_composer_hint_steer": "Enter steers",
|
||||
"com_ui_composer_hint_steer_verb": "steers",
|
||||
"com_ui_composer_hint_stop": "Esc to stop",
|
||||
"com_ui_composer_hint_typing": "Enter to send · Shift+Enter for newline",
|
||||
"com_ui_composer_hint_uploading": "Uploading {{count}} files…",
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ const showShortcutsDialog = atom<boolean>({
|
|||
default: false,
|
||||
});
|
||||
|
||||
/** The file manager is a dialog reached from the account menu rather than a
|
||||
* side panel, so the shortcut that opens it needs a way in from anywhere. */
|
||||
const showFilesDialog = atom<boolean>({
|
||||
key: 'showFilesDialog',
|
||||
default: false,
|
||||
});
|
||||
|
||||
export type KeyboardDeleteTarget = {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
|
|
@ -96,6 +103,7 @@ export default {
|
|||
queriesEnabled,
|
||||
isEditingBadges,
|
||||
showShortcutsDialog,
|
||||
showFilesDialog,
|
||||
keyboardDeleteTarget,
|
||||
customShortcuts,
|
||||
chatBadges,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue