👷 ci: Type-check the Client Workspace (#13560)

The `client/` workspace was never type-checked: the existing typecheck
job only covered `packages/` and `api/`, and Vite/esbuild transpiles
without type-checking, so type errors shipped through every CI gate.

- Add a `typecheck` job to frontend-review.yml running `tsc --noEmit`
  over `client/` (zero tolerance), reusing the data-provider +
  client-package build artifacts. Triggers on `client/**`,
  `packages/client/**`, `packages/data-provider/**`.
- Fix all 168 pre-existing client type errors this surfaced (source +
  tests), including genuine latent bugs:
  - `getFileConfig()` was typed as merged `FileConfig`, but the server
    returns the raw config that `mergeFileConfig()` consumes (`TFileConfig`).
  - SidePanel/Agents `Retrieval`/`ImageVision` were bound to `AgentForm`
    but use the assistants `Capabilities` enum → `AssistantForm`.
  - `useSearchResultsByTurn` read a `sources` field its type lacked.
  - Removed orphaned dead code: `Artifacts/Mermaid.tsx` (imported a
    never-installed dep) and dead barrel re-exports (`./Plugins`, `./MCPAuth`).
- Narrow `client/tsconfig.json` to the client app (drop `../e2e` and
  `../config/translations`, which reference backend/tooling modules) so
  the gate's scope matches its trigger.

No `any`/`@ts-ignore`/`as unknown as`. Localized newly-surfaced strings.
This commit is contained in:
Danny Avila 2026-06-06 18:40:31 -04:00 • committed by GitHub
parent 727dd3bea4
commit 265d660076
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 407 additions and 399 deletions

View file

@ -4,6 +4,7 @@ on:
pull_request:
paths:
- 'client/**'
- 'packages/client/**'
- 'packages/data-provider/**'
permissions:
@ -76,6 +77,50 @@ jobs:
path: packages/client/dist
retention-days: 2
typecheck:
name: TypeScript type checks (client)
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download client-package build
uses: actions/download-artifact@v4
with:
name: build-client-package
path: packages/client/dist
- name: Type check client
run: npm run typecheck
working-directory: client
test-ubuntu:
name: 'Tests: Ubuntu (shard ${{ matrix.shard }}/4)'
needs: build

View file

@ -1,7 +1,7 @@
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint } from 'librechat-data-provider';
import type { TAgentsEndpoint, TEphemeralAgent } from 'librechat-data-provider';
import {
useMCPServerManager,
useSearchApiKeyForm,
@ -170,7 +170,7 @@ export default function BadgeRowProvider({
if (prev == null) {
/** ephemeralAgent is null — use localStorage defaults */
if (hasOverrides || mcpOverrides) {
const result = { ...initialValues };
const result: TEphemeralAgent = { ...initialValues };
if (mcpOverrides) {
result.mcp = mcpOverrides;
}

View file

@ -16,17 +16,17 @@ const LiveMessage: React.FC<LiveMessageProps> = ({
useEffect(() => {
if (ariaLive === 'assertive') {
announceAssertive(message);
announceAssertive({ message });
} else if (ariaLive === 'polite') {
announcePolite(message);
announcePolite({ message });
}
}, [message, ariaLive, announceAssertive, announcePolite]);
useEffect(() => {
return () => {
if (clearOnUnmount === true || clearOnUnmount === 'true') {
announceAssertive('');
announcePolite('');
announceAssertive({ message: '' });
announcePolite({ message: '' });
}
};
}, [clearOnUnmount, announceAssertive, announcePolite]);

View file

@ -1,8 +1,8 @@
import React, { useMemo, useEffect, useCallback, useRef } from 'react';
import { AutoSizer, List as VirtualList, WindowScroller } from 'react-virtualized';
import { throttle } from 'lodash';
import { Spinner } from '@librechat/client';
import { PermissionBits } from 'librechat-data-provider';
import { AutoSizer, List as VirtualList, WindowScroller } from 'react-virtualized';
import type t from 'librechat-data-provider';
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { useAgentCategories, useLocalize } from '~/hooks';
@ -175,7 +175,7 @@ const VirtualizedAgentGrid: React.FC<VirtualizedAgentGridProps> = ({
const globalIndex = index * cardsPerRow + cardIndex;
return (
<div key={`${agent.id}-${globalIndex}`} role="gridcell">
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
<AgentCard agent={agent} onSelect={onSelectAgent} />
</div>
);
})}
@ -282,7 +282,7 @@ const VirtualizedAgentGrid: React.FC<VirtualizedAgentGridProps> = ({
const rowCount = getRowCount(currentAgents.length, cardsPerRow);
return (
<div ref={registerChild}>
<div ref={registerChild as React.LegacyRef<HTMLDivElement>}>
<VirtualList
ref={listRef}
autoHeight

View file

@ -272,7 +272,7 @@ describe('ErrorDisplay', () => {
});
it('handles null/undefined errors', () => {
render(<ErrorDisplay error={null} />);
render(<ErrorDisplay error={{}} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(

View file

@ -1,7 +1,6 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { jest } from '@jest/globals';
import type * as t from 'librechat-data-provider';
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
@ -48,7 +47,9 @@ const mockRowRenderer = jest.fn();
jest.mock('react-virtualized', () => {
const ReactActual = jest.requireActual<typeof import('react')>('react');
const mockRowRendererRef = { current: jest.fn() };
const mockRowRendererRef: { current: VirtualListMockProps['rowRenderer'] | null } = {
current: null,
};
return {
AutoSizer: ({

View file

@ -1,7 +1,6 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { jest } from '@jest/globals';
import type t from 'librechat-data-provider';
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
@ -166,9 +165,15 @@ jest.mock('../SmartLoader', () => ({
}));
jest.mock('../AgentCard', () => {
return function MockAgentCard({ agent, onClick }: { agent: t.Agent; onClick: () => void }) {
return function MockAgentCard({
agent,
onSelect,
}: {
agent: t.Agent;
onSelect: (agent: t.Agent) => void;
}) {
return (
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
<div data-testid={`agent-card-${agent.id}`} onClick={() => onSelect(agent)}>
<h3>{agent.name}</h3>
<p>{agent.description}</p>
</div>

View file

@ -1,176 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Button } from '@librechat/client';
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
import { artifactFlowchartConfig } from '~/utils/mermaid';
interface MermaidDiagramProps {
content: string;
isDarkMode?: boolean;
}
let mermaidPromise: Promise<typeof import('mermaid').default> | null = null;
const loadMermaid = () => {
if (!mermaidPromise) {
mermaidPromise = import('mermaid').then((mod) => mod.default);
}
return mermaidPromise;
};
const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content, isDarkMode = true }) => {
const mermaidRef = useRef<HTMLDivElement>(null);
const transformRef = useRef<ReactZoomPanPinchRef>(null);
const [isRendered, setIsRendered] = useState(false);
const theme = isDarkMode ? 'dark' : 'neutral';
const bgColor = isDarkMode ? '#212121' : '#FFFFFF';
useEffect(() => {
let isMounted = true;
const renderDiagram = async () => {
try {
const mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
theme,
securityLevel: 'sandbox',
flowchart: artifactFlowchartConfig,
});
if (!mermaidRef.current) {
return;
}
const { svg } = await mermaid.render('mermaid-diagram', content);
mermaidRef.current.innerHTML = svg;
const svgElement = mermaidRef.current.querySelector('svg');
if (svgElement) {
svgElement.style.width = '100%';
svgElement.style.height = '100%';
}
if (isMounted) {
setIsRendered(true);
}
} catch (error) {
console.error('Mermaid rendering error:', error);
if (mermaidRef.current) {
mermaidRef.current.innerHTML = 'Error rendering diagram';
}
}
};
renderDiagram();
return () => {
isMounted = false;
};
}, [content, theme]);
const centerAndFitDiagram = useCallback(() => {
if (transformRef.current && mermaidRef.current) {
const { centerView, zoomToElement } = transformRef.current;
zoomToElement(mermaidRef.current as HTMLElement);
centerView(1, 0);
}
}, []);
useEffect(() => {
if (isRendered) {
centerAndFitDiagram();
}
}, [isRendered, centerAndFitDiagram]);
const handlePanning = useCallback(() => {
if (!transformRef.current) {
return;
}
const { state, instance } = transformRef.current;
if (!state || !instance) {
return;
}
const { scale, positionX, positionY } = state;
const { wrapperComponent, contentComponent } = instance;
if (!wrapperComponent || !contentComponent) {
return;
}
const wrapperRect = wrapperComponent.getBoundingClientRect();
const contentRect = contentComponent.getBoundingClientRect();
const maxX = wrapperRect.width - contentRect.width * scale;
const maxY = wrapperRect.height - contentRect.height * scale;
let newX = positionX;
let newY = positionY;
if (newX > 0) {
newX = 0;
}
if (newY > 0) {
newY = 0;
}
if (newX < maxX) {
newX = maxX;
}
if (newY < maxY) {
newY = maxY;
}
if (newX !== positionX || newY !== positionY) {
instance.setTransformState(scale, newX, newY);
}
}, []);
return (
<div
className="relative h-screen w-screen cursor-move p-5"
style={{ backgroundColor: bgColor }}
>
<TransformWrapper
ref={transformRef}
initialScale={1}
minScale={0.1}
maxScale={10}
limitToBounds={false}
centerOnInit={true}
initialPositionY={0}
wheel={{ step: 0.1 }}
panning={{ velocityDisabled: true }}
alignmentAnimation={{ disabled: true }}
onPanning={handlePanning}
>
{({ zoomIn, zoomOut }) => (
<>
<TransformComponent
wrapperStyle={{ width: '100%', height: '100%', overflow: 'hidden' }}
>
<div
ref={mermaidRef}
style={{ width: 'auto', height: 'auto', minWidth: '100%', minHeight: '100%' }}
/>
</TransformComponent>
<div className="absolute bottom-2 right-2 flex space-x-2">
<Button onClick={() => zoomIn(0.1)} variant="outline" size="icon">
<ZoomIn className="h-4 w-4" />
</Button>
<Button onClick={() => zoomOut(0.1)} variant="outline" size="icon">
<ZoomOut className="h-4 w-4" />
</Button>
<Button onClick={centerAndFitDiagram} variant="outline" size="icon">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</>
)}
</TransformWrapper>
</div>
);
};
export default MermaidDiagram;

View file

@ -1,7 +1,7 @@
import reactRouter from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { getByTestId, render, waitFor } from 'test/layout-test-utils';
import type { TStartupConfig } from 'librechat-data-provider';
import { getByTestId, render, waitFor } from 'test/layout-test-utils';
import * as endpointQueries from '~/data-provider/Endpoints/queries';
import * as miscDataProvider from '~/data-provider/Misc/queries';
import * as authMutations from '~/data-provider/Auth/mutations';
@ -176,7 +176,7 @@ test('calls loginUser.mutate on login', async () => {
});
test('Navigates to / on successful login', async () => {
const { getByLabelText, history } = setup({
const { getByLabelText } = setup({
// @ts-ignore - we don't need all parameters of the QueryObserverResult
useLoginUserReturnValue: {
isLoading: false,
@ -202,5 +202,5 @@ test('Navigates to / on successful login', async () => {
await userEvent.type(passwordInput, 'password');
await userEvent.click(submitButton);
waitFor(() => expect(history.location.pathname).toBe('/'));
waitFor(() => expect(window.location.pathname).toBe('/'));
});

View file

@ -1,9 +1,9 @@
import { render, getByTestId } from 'test/layout-test-utils';
import userEvent from '@testing-library/user-event';
import type { TStartupConfig } from 'librechat-data-provider';
import * as endpointQueries from '~/data-provider/Endpoints/queries';
import * as miscDataProvider from '~/data-provider/Misc/queries';
import * as authMutations from '~/data-provider/Auth/mutations';
import { render, getByTestId } from 'test/layout-test-utils';
import * as authQueries from '~/data-provider/Auth/queries';
import Login from '../LoginForm';
@ -18,8 +18,10 @@ const mockStartupConfig: TStartupConfig = {
githubLoginEnabled: true,
googleLoginEnabled: true,
openidLoginEnabled: true,
appleLoginEnabled: false,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
openidAutoRedirect: false,
samlLoginEnabled: true,
samlLabel: 'Test SAML',
samlImageUrl: 'http://test-server.com',
@ -33,9 +35,11 @@ const mockStartupConfig: TStartupConfig = {
enabled: false,
},
emailEnabled: false,
checkBalance: false,
showBirthdayIcon: false,
helpAndFaqURL: '',
sharedLinksEnabled: true,
publicSharedLinksEnabled: true,
allowAccountDeletion: true,
};
const setup = ({
@ -106,15 +110,25 @@ beforeEach(() => {
test('renders login form', () => {
const { getByLabelText } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
<Login
onSubmit={mockLogin}
startupConfig={mockStartupConfig}
error={undefined}
setError={jest.fn()}
/>,
);
expect(getByLabelText(/email/i)).toBeInTheDocument();
expect(getByLabelText(/password/i)).toBeInTheDocument();
});
test('submits login form', async () => {
const { getByLabelText, getByRole } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
const { getByLabelText } = render(
<Login
onSubmit={mockLogin}
startupConfig={mockStartupConfig}
error={undefined}
setError={jest.fn()}
/>,
);
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
@ -128,8 +142,13 @@ test('submits login form', async () => {
});
test('displays validation error messages', async () => {
const { getByLabelText, getByRole, getByText } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
const { getByLabelText, getByText } = render(
<Login
onSubmit={mockLogin}
startupConfig={mockStartupConfig}
error={undefined}
setError={jest.fn()}
/>,
);
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);

View file

@ -32,7 +32,7 @@ export const data: TModelSpec[] = [
// iconURL: 'https://i.ytimg.com/vi/SaneSRqePVY/maxresdefault.jpg',
iconURL: EModelEndpoint.openAI, // Allow using project-included icons
preset: {
chatGptLabel: 'Vision Helper',
modelLabel: 'Vision Helper',
greeting: "What's up!!",
endpoint: EModelEndpoint.openAI,
model: 'gpt-4-turbo',

View file

@ -1,8 +1,8 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import type { MutableSnapshot } from 'recoil';
import { render, screen, fireEvent, act } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import type { MutableSnapshot } from 'recoil';
import Attachment, { AttachmentGroup } from '../Attachment';
import store from '~/store';
@ -198,9 +198,8 @@ describe('Attachment routing for tool artifacts', () => {
* yet); use it as the canonical "unrouted text" example. */
const json = baseAttachment({
filename: 'data.json',
type: 'application/json',
text: '{"a":1,"b":2}',
} as Partial<TAttachment>);
});
const { container } = renderWith(<Attachment attachment={json} />);
expect(container.querySelector('pre')).not.toBeNull();
expect(screen.queryByTestId('mermaid-render')).not.toBeInTheDocument();
@ -544,10 +543,9 @@ describe('ToolArtifactCard click behaviour', () => {
const xlsx = baseAttachment({
file_id: 'just-resolved-xlsx',
filename: 'data.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
text: '<table>resolved</table>',
textFormat: 'html',
} as Partial<TAttachment>);
});
const initializeState = (snap: MutableSnapshot) => {
snap.set(store.isSubmittingFamily(0), false);
snap.set(store.artifactsVisibility, false);
@ -580,10 +578,9 @@ describe('ToolArtifactCard click behaviour', () => {
const xlsx = baseAttachment({
file_id: 'one-shot-xlsx',
filename: 'data.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
text: '<table>resolved</table>',
textFormat: 'html',
} as Partial<TAttachment>);
});
const initializeState = (snap: MutableSnapshot) => {
snap.set(store.isSubmittingFamily(0), false);
snap.set(store.artifactsVisibility, false);
@ -710,15 +707,13 @@ describe('AttachmentGroup routing', () => {
const empty = baseAttachment({
file_id: 'empty-zip',
filename: 'placeholder.zip',
type: 'application/zip',
bytes: 0,
} as Partial<TAttachment>);
});
const real = baseAttachment({
file_id: 'real-zip',
filename: 'archive.zip',
type: 'application/zip',
bytes: 1024,
} as Partial<TAttachment>);
});
const { container } = renderWith(<AttachmentGroup attachments={[empty, real]} />);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_show_n_files' }));
const chips = Array.from(container.querySelectorAll('[data-testid="file-container"]'));
@ -733,26 +728,22 @@ describe('AttachmentGroup routing', () => {
const first = baseAttachment({
file_id: 'file-a',
filename: 'a.zip',
type: 'application/zip',
} as Partial<TAttachment>);
});
const second = baseAttachment({
file_id: 'file-b',
filename: 'b.zip',
type: 'application/zip',
} as Partial<TAttachment>);
});
const json = baseAttachment({
file_id: 'file-c',
filename: 'c.json',
type: 'application/json',
text: '{"c":true}',
} as Partial<TAttachment>);
});
const image = baseAttachment({
file_id: 'image-a',
filename: 'preview.png',
type: 'image/png',
width: 16,
height: 16,
} as Partial<TAttachment>);
});
const { container } = renderWith(
<AttachmentGroup attachments={[first, second, json, image]} />,
@ -784,9 +775,8 @@ describe('AttachmentGroup routing', () => {
const sandboxFile = baseAttachment({
file_id: 'sandbox-zip',
filename: 'archive-deadbe.zip',
type: 'application/zip',
bytes: 1024,
} as Partial<TAttachment>);
});
const { container } = renderWith(<AttachmentGroup attachments={[sandboxFile]} />);
const chip = container.querySelector('[data-testid="file-container"]');
expect(chip?.textContent).toBe('archive-deadbe.zip');
@ -802,9 +792,8 @@ describe('AttachmentGroup routing', () => {
const sandboxDotfile = baseAttachment({
file_id: 'sandbox-config',
filename: '_.config-abcdef.zip',
type: 'application/zip',
bytes: 12,
} as Partial<TAttachment>);
});
const { container } = renderWith(<AttachmentGroup attachments={[sandboxDotfile]} />);
const chip = container.querySelector('[data-testid="file-container"]');
expect(chip?.textContent).toBe('.config.zip');
@ -823,7 +812,6 @@ describe('AttachmentGroup routing', () => {
baseAttachment({
file_id: 'pending-1',
filename: 'data.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
status: 'pending',
} as Partial<TAttachment>),
baseAttachment({
@ -859,7 +847,6 @@ describe('AttachmentGroup routing', () => {
baseAttachment({
file_id: 'c',
filename: 'data.json',
type: 'application/json',
text: '{"a":1}',
} as Partial<TAttachment>),
baseAttachment({

View file

@ -1,8 +1,8 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import type { MutableSnapshot } from 'recoil';
import { render, screen } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import type { MutableSnapshot } from 'recoil';
import LogContent from '../LogContent';
import store from '~/store';
@ -110,9 +110,8 @@ describe('LogContent attachment routing', () => {
const json = baseAttachment({
file_id: 'c',
filename: 'data.json',
type: 'application/json',
text: '{"a":1,"b":2}',
} as Partial<TAttachment>);
});
const { container } = renderWith(<LogContent output="" attachments={[json]} />);
expect(container.querySelector('pre')).not.toBeNull();
expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument();
@ -123,8 +122,7 @@ describe('LogContent attachment routing', () => {
const zip = baseAttachment({
file_id: 'd',
filename: 'archive.zip',
type: 'application/zip',
} as Partial<TAttachment>);
});
renderWith(<LogContent output="" attachments={[zip]} />);
expect(screen.getByTestId('log-link')).toHaveAttribute('data-filename', 'archive.zip');
});
@ -137,9 +135,8 @@ describe('LogContent attachment routing', () => {
const pptx = baseAttachment({
file_id: 'e',
filename: 'slides.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: '<!DOCTYPE html><body><ol><li>Slide 1</li></ol></body>',
} as Partial<TAttachment>);
});
renderWith(<LogContent output="" attachments={[pptx]} />);
expect(screen.getByText('slides.pptx')).toBeInTheDocument();
});
@ -151,9 +148,8 @@ describe('LogContent attachment routing', () => {
const pptx = baseAttachment({
file_id: 'e2',
filename: 'slides.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: undefined as unknown as string,
} as Partial<TAttachment>);
text: undefined,
});
renderWith(<LogContent output="" attachments={[pptx]} />);
expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument();
expect(screen.getByTestId('log-link')).toHaveAttribute('data-filename', 'slides.pptx');
@ -168,10 +164,9 @@ describe('LogContent attachment routing', () => {
const expired = baseAttachment({
file_id: 'x-expired',
filename: 'slides.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: '<!DOCTYPE html><body><ol><li>Slide 1</li></ol></body>',
expiresAt: Date.now() - 60_000,
} as Partial<TAttachment>);
});
renderWith(<LogContent output="" attachments={[expired]} />);
// No panel card and no log-link (the expired branch returns plain text).
expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument();
@ -211,7 +206,6 @@ describe('LogContent attachment routing', () => {
* bucket, so it would no longer satisfy the "inline pre" check
* below. */
filename: 'notes.json',
type: 'application/json',
text: '{"a":1,"b":2}',
} as Partial<TAttachment>),
] as TAttachment[];

View file

@ -8,15 +8,14 @@ import type {
SubagentAggregatorState,
} from '~/utils/subagentContent';
import type { SubagentProgress } from '~/store/subagents';
import {
foldSubagentEvent,
foldSubagentEventIntoTicker,
initSubagentAggregatorState,
initSubagentTickerState,
} from '~/utils/subagentContent';
import { subagentProgressByToolCallId } from '~/store/subagents';
import SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall';
import { subagentProgressByToolCallId } from '~/store/subagents';
jest.mock('~/hooks', () => ({
useLocalize:
@ -622,7 +621,7 @@ describe('SubagentCall — dialog content', () => {
);
openSubagentDialog();
expect(screen.getByText('raw final text')).toBeInTheDocument();
rerender(<RecoilRoot />);
rerender(<RecoilRoot>{null}</RecoilRoot>);
});
it('renders persistedContent parts when no live events are available (page-refresh flow)', () => {

View file

@ -71,7 +71,6 @@ const textAttachment = (overrides: Partial<TAttachment> = {}): TAttachment =>
* bearing, downloadable, expandable) without the panel coupling. */
filename: 'output.json',
filepath: '/files/output.json',
type: 'application/json',
text: '{"a":1,"b":2,"c":3}',
...overrides,
}) as TAttachment;
@ -190,8 +189,7 @@ describe('AttachmentGroup', () => {
textAttachment({
file_id: 'b',
filename: 'archive.zip',
type: 'application/zip',
text: undefined as unknown as string,
text: undefined,
}),
] as TAttachment[];
const { container } = render(<AttachmentGroup attachments={attachments} />);
@ -205,8 +203,7 @@ describe('AttachmentGroup', () => {
file_id: 'placeholder',
filename: 'placeholder.zip',
filepath: '',
type: 'application/zip',
text: undefined as unknown as string,
text: undefined,
}),
textAttachment({
file_id: 'json',
@ -230,8 +227,7 @@ describe('AttachmentGroup', () => {
textAttachment({
file_id: 'archive',
filename: 'archive.zip',
type: 'application/zip',
text: undefined as unknown as string,
text: undefined,
}),
textAttachment({
file_id: 'json',

View file

@ -1,5 +1,4 @@
import type { TAttachment } from 'librechat-data-provider';
import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts';
import {
artifactTypeForAttachment,
attachmentSalience,
@ -8,6 +7,7 @@ import {
isInternalSandboxArtifact,
isTextAttachment,
} from '../attachmentTypes';
import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts';
const baseAttachment = (overrides: Partial<TAttachment> = {}): TAttachment =>
({
@ -138,9 +138,8 @@ describe('artifactTypeForAttachment', () => {
* pipeline instead. */
const attachment = baseAttachment({
filename: 'photo.jpg',
type: 'image/jpeg',
text: undefined,
} as Partial<TAttachment>);
});
expect(artifactTypeForAttachment(attachment)).toBeNull();
});
});

View file

@ -35,11 +35,11 @@ Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
describe('UIResourceCarousel', () => {
const mockUIResources: UIResource[] = [
{ uri: 'resource1', mimeType: 'text/html', text: 'Resource 1' },
{ uri: 'resource2', mimeType: 'text/html', text: 'Resource 2' },
{ uri: 'resource3', mimeType: 'text/html', text: 'Resource 3' },
{ uri: 'resource4', mimeType: 'text/html', text: 'Resource 4' },
{ uri: 'resource5', mimeType: 'text/html', text: 'Resource 5' },
{ resourceId: 'resource1', uri: 'resource1', mimeType: 'text/html', text: 'Resource 1' },
{ resourceId: 'resource2', uri: 'resource2', mimeType: 'text/html', text: 'Resource 2' },
{ resourceId: 'resource3', uri: 'resource3', mimeType: 'text/html', text: 'Resource 3' },
{ resourceId: 'resource4', uri: 'resource4', mimeType: 'text/html', text: 'Resource 4' },
{ resourceId: 'resource5', uri: 'resource5', mimeType: 'text/html', text: 'Resource 5' },
];
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;

View file

@ -104,7 +104,7 @@ describe('MessageIcon render cycles', () => {
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={agent1} />);
iconRenderCount.current = 0;
const agent2 = makeAgent({ avatar: { filepath: '/images/new-avatar.png' } });
const agent2 = makeAgent({ avatar: { filepath: '/images/new-avatar.png', source: 'local' } });
rerender(<MessageIcon iconData={baseIconData} agent={agent2} />);
expect(iconRenderCount.current).toBe(1);

View file

@ -62,8 +62,14 @@ describe('MessageIcon arePropsEqual', () => {
it('returns false when agent avatar filepath changes', () => {
expect(
arePropsEqual(
{ iconData: baseIconData, agent: makeAgent({ avatar: { filepath: '/a.png' } }) },
{ iconData: baseIconData, agent: makeAgent({ avatar: { filepath: '/b.png' } }) },
{
iconData: baseIconData,
agent: makeAgent({ avatar: { filepath: '/a.png', source: 'local' } }),
},
{
iconData: baseIconData,
agent: makeAgent({ avatar: { filepath: '/b.png', source: 'local' } }),
},
),
).toBe(false);
});

View file

@ -1,6 +1,12 @@
import { useState, useRef } from 'react';
import { Trans } from 'react-i18next';
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
import {
PermissionTypes,
Permissions,
PermissionBits,
ResourceType,
} from 'librechat-data-provider';
import {
Label,
Button,
@ -13,19 +19,13 @@ import {
useToastContext,
OGDialogContent,
} from '@librechat/client';
import {
PermissionTypes,
Permissions,
PermissionBits,
ResourceType,
} from 'librechat-data-provider';
import type { TSharedLinkGetResponse } from 'librechat-data-provider';
import GenericGrantAccessDialog from '~/components/Sharing/GenericGrantAccessDialog';
import {
useCreateSharedLinkMutation,
useUpdateSharedLinkMutation,
useDeleteSharedLinkMutation,
} from '~/data-provider';
import GenericGrantAccessDialog from '~/components/Sharing/GenericGrantAccessDialog';
import { useHasAccess, useResourcePermissions, useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { buildShareLinkUrl } from '~/utils';
@ -220,7 +220,7 @@ export default function SharedLinkButton({
<GenericGrantAccessDialog
resourceType={ResourceType.SHARED_LINK}
resourceDbId={share?._id}
resourceName={share?.shareId}
resourceName={share?.shareId ?? undefined}
>
<TooltipAnchor
description={localize('com_ui_shared_link_manage_access')}

View file

@ -3,10 +3,13 @@ import { PlusIcon } from 'lucide-react';
import { Button, Checkbox, DotsIcon, FileIcon } from '@librechat/client';
import type { ColumnDef } from '@tanstack/react-table';
import type { TFile } from 'librechat-data-provider';
import type { TVectorStore } from '~/common';
import { formatDate, getFileType } from '~/utils';
import { useLocalize } from '~/hooks';
export const fileTableColumns: ColumnDef<TFile>[] = [
type TFileWithVectors = TFile & { vectorsAttached?: TVectorStore[] };
export const fileTableColumns: ColumnDef<TFileWithVectors>[] = [
{
id: 'select',
header: ({ table }) => {
@ -53,7 +56,7 @@ export const fileTableColumns: ColumnDef<TFile>[] = [
size: '150px',
},
accessorKey: 'filename',
header: ({ column }) => {
header: () => {
const localize = useLocalize();
return <>{localize('com_ui_name')}</>;
},
@ -68,7 +71,8 @@ export const fileTableColumns: ColumnDef<TFile>[] = [
return 'Vector Stores';
},
cell: ({ row }) => {
const { vectorsAttached: attachedVectorStores } = row.original;
const localize = useLocalize();
const { vectorsAttached: attachedVectorStores = [] } = row.original;
return (
<>
{attachedVectorStores.map((vectorStore, index) => {
@ -80,7 +84,7 @@ export const fileTableColumns: ColumnDef<TFile>[] = [
>
<PlusIcon className="h-3 w-3" />
&nbsp;
{attachedVectorStores.length - index} more
{localize('com_ui_more_count', { 0: attachedVectorStores.length - index })}
</span>
);
}
@ -100,17 +104,19 @@ export const fileTableColumns: ColumnDef<TFile>[] = [
{
accessorKey: 'updatedAt',
header: () => {
const localize = useLocalize();
return 'Modified';
},
cell: ({ row }) => formatDate(row.original.updatedAt),
cell: ({ row }) => {
const { updatedAt } = row.original;
return formatDate(updatedAt instanceof Date ? updatedAt.toISOString() : (updatedAt ?? ''));
},
},
{
accessorKey: 'actions',
header: () => {
return 'Actions';
},
cell: ({ row }) => {
cell: () => {
return (
<>
<Button className="w-min content-center bg-transparent text-gray-500 hover:bg-slate-200">

View file

@ -2,6 +2,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, DotsIcon, TrashIcon } from '@librechat/client';
import { TVectorStore } from '~/common';
import { useLocalize } from '~/hooks';
type VectorStoreListItemProps = {
vectorStore: TVectorStore;
@ -13,6 +14,7 @@ export default function VectorStoreListItem({
deleteVectorStore,
}: VectorStoreListItemProps) {
const navigate = useNavigate();
const localize = useLocalize();
return (
<div
onClick={() => {
@ -26,7 +28,10 @@ export default function VectorStoreListItem({
</div>
<div className="w-2/6 text-gray-500">
<p>
{vectorStore.file_counts.total} Files ({vectorStore.bytes / 1000}KB)
{localize('com_ui_files_count_size', {
0: vectorStore.file_counts?.total ?? 0,
1: (vectorStore.bytes ?? 0) / 1000,
})}
</p>
<p className="text-sm">{vectorStore.created_at.toString()}</p>
</div>

View file

@ -20,7 +20,7 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
return (
<Menu.MenuProvider>
<Menu.MenuProvider placement={collapsed ? 'right-end' : undefined}>
<Menu.MenuButton
ref={accountSettingsButtonRef}
aria-label={localize('com_nav_account_settings')}
@ -50,7 +50,6 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
<Menu.Menu
portal
className="account-settings-popover popover-ui z-[125] w-[305px] rounded-lg md:w-[244px]"
placement={collapsed ? 'right-end' : undefined}
style={{
transformOrigin: collapsed ? 'left bottom' : 'bottom',
translate: collapsed ? '4px 0' : '0 -4px',

View file

@ -1,12 +1,12 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { BrowserRouter } from 'react-router-dom';
import { dataService } from 'librechat-data-provider';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Agent } from 'librechat-data-provider';
// Mock store before importing FavoritesList
@ -71,7 +71,7 @@ jest.mock('~/hooks/Input/useSelectMention', () => () => ({
onSelectSpec: mockOnSelectSpec,
}));
const mockUseGetStartupConfig = jest.fn(() => ({
const mockUseGetStartupConfig = jest.fn((): { data?: { modelSpecs: { list: unknown[] } } } => ({
data: { modelSpecs: { list: [] as unknown[] } },
}));

View file

@ -11,6 +11,7 @@ import {
Progress,
} from '@librechat/client';
import type { TUser, TVerify2FARequest } from 'librechat-data-provider';
import type { Variants } from 'framer-motion';
import {
useConfirmTwoFactorMutation,
useDisableTwoFactorMutation,
@ -24,7 +25,7 @@ import store from '~/store';
export type Phase = 'setup' | 'qr' | 'verify' | 'backup' | 'disable';
const phaseVariants = {
const phaseVariants: Variants = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: 'easeOut' } },
exit: { opacity: 0, scale: 0.95, transition: { duration: 0.3, ease: 'easeIn' } },

View file

@ -20,7 +20,7 @@ export default function ArchivedChats() {
title={localize('com_nav_archived_chats')}
className="max-w-[1000px]"
showCancelButton={false}
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
main={<ArchivedChatsTable onOpenChange={setIsOpen} />}
/>
</OGDialog>
</div>

View file

@ -40,8 +40,9 @@ export default function AutoSendTextSelector() {
}
};
const handleInputChange = (value: number[] | null) => {
const newValue = value ? value[0] : 3;
const handleInputChange = (value: string | number | null) => {
const parsed = typeof value === 'number' ? value : parseInt(String(value ?? ''), 10);
const newValue = Number.isNaN(parsed) ? 3 : parsed;
setDelayValue(newValue);
if (isEnabled) {
setAutoSendText(newValue);

View file

@ -31,6 +31,7 @@ export default function AutoSendPrompt({
checked={autoSendPrompts}
tabIndex={-1}
aria-hidden="true"
aria-label={localize('com_nav_auto_send_prompts')}
className="pointer-events-none"
/>
{localize('com_nav_auto_send_prompts')}

View file

@ -3,7 +3,6 @@
*/
import * as React from 'react';
import { render, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Agent } from 'librechat-data-provider';

View file

@ -1,12 +1,12 @@
import { Checkbox } from '@librechat/client';
import { Capabilities } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { AgentForm } from '~/common';
import type { AssistantForm } from '~/common';
import { useLocalize } from '~/hooks';
export default function ImageVision() {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const methods = useFormContext<AssistantForm>();
const { control, setValue, getValues } = methods;
return (
@ -21,10 +21,12 @@ export default function ImageVision() {
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value?.toString()}
aria-labelledby="image-vision-label"
/>
)}
/>
<label
id="image-vision-label"
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={Capabilities.image_vision}
onClick={() =>

View file

@ -112,7 +112,6 @@ export function AvatarMenu({
isOpen={isOpen}
setIsOpen={setIsOpen}
menuId="agent-avatar-menu"
placement="bottom"
gutter={8}
portal
mountByState

View file

@ -9,15 +9,15 @@ import {
HoverCardPortal,
HoverCardTrigger,
} from '@librechat/client';
import type { AssistantForm } from '~/common';
import OptionHover from '~/components/SidePanel/Parameters/OptionHover';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
import { cn } from '~/utils/';
export default function Retrieval({ retrievalModels }: { retrievalModels: Set<string> }) {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const methods = useFormContext<AssistantForm>();
const { control, setValue, getValues } = methods;
const model = useWatch({ control, name: 'model' });
@ -44,11 +44,13 @@ export default function Retrieval({ retrievalModels }: { retrievalModels: Set<st
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value?.toString()}
aria-labelledby="retrieval-label"
/>
)}
/>
<div className="flex items-center space-x-2">
<label
id="retrieval-label"
className={cn(
'form-check-label text-token-text-primary w-full select-none',
isDisabled ? 'cursor-no-drop opacity-50' : 'cursor-pointer',

View file

@ -1,7 +1,7 @@
import '@testing-library/jest-dom/extend-expect';
import { render, fireEvent } from '@testing-library/react';
import type { VersionContext } from '../types';
import VersionContent from '../VersionContent';
import { VersionContext } from '../VersionPanel';
const mockRestore = 'Restore';

View file

@ -1,7 +1,7 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import type { VersionRecord } from '../types';
import VersionItem from '../VersionItem';
import { VersionRecord } from '../VersionPanel';
jest.mock('~/hooks', () => ({
useLocalize: jest.fn().mockImplementation(() => (key, params) => {

View file

@ -1,5 +1,5 @@
import type { AgentState, VersionRecord } from '../types';
import { isActiveVersion } from '../isActiveVersion';
import type { AgentState, VersionRecord } from '../VersionPanel';
describe('isActiveVersion', () => {
const createVersion = (overrides = {}): VersionRecord => ({

View file

@ -2,7 +2,6 @@
* @jest-environment jsdom
*/
/* eslint-disable i18next/no-literal-string */
import { describe, it, expect } from '@jest/globals';
import { render, fireEvent } from '@testing-library/react';
import { FormProvider, useForm, type UseFormReturn } from 'react-hook-form';
import type { AgentForm } from '~/common';
@ -41,7 +40,15 @@ const defaultFormValues: AgentForm = {
description: null,
instructions: null,
model: 'gpt-4',
model_parameters: {},
model_parameters: {
temperature: 1,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
},
tools: [],
provider: 'openai',
agent_ids: [],
@ -62,7 +69,7 @@ const defaultFormValues: AgentForm = {
describe('AgentAvatar reset menu', () => {
it('clears preview and file state when reset is triggered', () => {
let methodsRef: UseFormReturn<AgentForm>;
let methodsRef: UseFormReturn<AgentForm> | undefined;
const Wrapper = () => {
methodsRef = useForm<AgentForm>({
defaultValues: {
@ -88,6 +95,10 @@ describe('AgentAvatar reset menu', () => {
const { getByTestId } = render(<Wrapper />);
fireEvent.click(getByTestId('reset-avatar'));
if (!methodsRef) {
throw new Error('Form methods were not initialized');
}
expect(methodsRef.getValues('avatar_preview')).toBe('');
expect(methodsRef.getValues('avatar_file')).toBeNull();
expect(methodsRef.getValues('avatar_action')).toBe('reset');

View file

@ -1,7 +1,6 @@
/**
* @jest-environment jsdom
*/
import { describe, it, expect, jest } from '@jest/globals';
import { Constants, type Agent } from 'librechat-data-provider';
import type { FieldNamesMarkedBoolean } from 'react-hook-form';
import type { AgentForm } from '~/common';
@ -18,7 +17,15 @@ const createForm = (): AgentForm => ({
description: null,
instructions: null,
model: 'gpt-4',
model_parameters: {},
model_parameters: {
temperature: 1,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
},
tools: [],
provider: 'openai',
agent_ids: [],
@ -70,7 +77,7 @@ describe('persistAvatarChanges', () => {
it('returns false for ephemeral agents', async () => {
const uploadAvatar = jest.fn();
const result = await persistAvatarChanges({
agentId: Constants.EPHEMERAL_AGENT_ID,
agentId: String(Constants.EPHEMERAL_AGENT_ID),
avatarActionState: 'upload',
avatarFile: new File(['avatar'], 'avatar.png', { type: 'image/png' }),
uploadAvatar,

View file

@ -5,4 +5,3 @@ export { default as MCPServerDialog } from './MCPServerDialog';
export { default as MCPServerCard } from './MCPServerCard';
export { default as MCPStatusBadge, getStatusDotColor } from './MCPStatusBadge';
export { default as MCPCardActions } from './MCPCardActions';
export { default as MCPAuth } from './MCPAuth';

View file

@ -182,6 +182,7 @@ export default function MemoryPanel() {
checked={referenceSavedMemories}
tabIndex={-1}
aria-hidden="true"
aria-label={localize('com_ui_use_memory')}
className="pointer-events-none mr-2"
/>
{localize('com_ui_use_memory')}

View file

@ -27,7 +27,9 @@ export default function UploadSkillDialog({ isOpen, setIsOpen }: UploadSkillDial
const { showToast } = useToastContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const { data: skillFileConfig = { fileConfig: defaultFileConfig } } = useGetFileConfig({
const {
data: skillFileConfig = { configuredSizeLimitMb: undefined, fileConfig: defaultFileConfig },
} = useGetFileConfig({
select: (data) => ({
configuredSizeLimitMb: data?.skills?.fileSizeLimit,
fileConfig: mergeFileConfig(data),

View file

@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import SkillsView from '../SkillsView';
const mockUseHasAccess = jest.fn(() => true);
const mockUseHasAccess = jest.fn((..._args: unknown[]) => true);
jest.mock(
'librechat-data-provider',

View file

@ -1,6 +1,7 @@
import { memo, useState, useContext, useCallback } from 'react';
import { Button } from '@librechat/client';
import { ChevronLeft, ChevronRight, FileText } from 'lucide-react';
import type { SourceData } from '~/components/Web/SourceHovercard';
import type { CitationProps } from './types';
import { SourceHovercard, FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog';
@ -40,6 +41,15 @@ function getFileCitationData(source?: FileCitationSource) {
};
}
function toSourceData(source: FileCitationSource): SourceData {
return {
link: source.link ?? '',
title: source.title,
attribution: source.attribution,
snippet: source.snippet,
};
}
interface CompositeCitationProps {
citationId?: string;
node?: {
@ -107,7 +117,7 @@ export function CompositeCitation(props: CompositeCitationProps) {
return (
<>
<SourceHovercard
source={currentSource}
source={toSourceData(currentSource)}
label={getCitationLabel()}
onMouseEnter={() => setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}
@ -329,7 +339,7 @@ export function Citation(props: CitationComponentProps) {
return (
<>
<SourceHovercard
source={refData}
source={toSourceData(refData)}
label={getCitationLabel()}
onMouseEnter={() => setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}

View file

@ -18,8 +18,8 @@ import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
import SourcesErrorBoundary from './SourcesErrorBoundary';
import { useFileDownload } from '~/data-provider';
import { useSearchContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn, triggerDownload } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
interface SourceItemProps {
@ -89,7 +89,6 @@ function SourceItem({ source, expanded = false }: SourceItemProps) {
</Ariakit.HovercardDisclosure>
<Ariakit.Hovercard
animated
gutter={16}
className={cn(
'z-[999] w-[320px] max-w-[calc(100vw-2rem)] rounded-xl border border-border-medium bg-surface-secondary p-3 text-text-primary shadow-lg',
@ -172,6 +171,7 @@ type AgentFileSource = {
filename: string;
bytes?: number;
type?: string;
source?: string;
pages?: number[];
relevance?: number;
pageRelevance?: Record<number, number>;

View file

@ -1,2 +1 @@
export * from './ui';
export * from './Plugins';

View file

@ -1,3 +1,4 @@
export { Button } from '@librechat/client';
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';

View file

@ -34,9 +34,9 @@ async function fetchAllAgentPages(params: t.AgentListParams): Promise<t.AgentLis
object: 'list',
data: pages.flatMap((p) => p.data),
has_more: false,
after: null,
first_id: pages[0]?.first_id ?? null,
last_id: lastPage?.last_id ?? null,
after: undefined,
first_id: pages[0]?.first_id ?? '',
last_id: lastPage?.last_id ?? '',
};
}

View file

@ -1,6 +1,6 @@
import { useRecoilValue } from 'recoil';
import { QueryKeys, dataService } from 'librechat-data-provider';
import { useQuery } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import store from '~/store';
@ -32,7 +32,7 @@ export const useGraphTokenQuery = (
return useQuery({
queryKey: [QueryKeys.graphToken, scopes],
queryFn: () => dataService.getGraphApiToken({ scopes }),
queryFn: () => dataService.getGraphApiToken({ scopes: scopes ?? '' }),
enabled,
staleTime: 50 * 60 * 1000, // 50 minutes (tokens expire in 60 minutes)
retry: 1,

View file

@ -38,10 +38,10 @@ export const useGetAgentFiles = <TData = t.TFile[]>(
);
};
export const useGetFileConfig = <TData = t.FileConfig>(
config?: UseQueryOptions<t.FileConfig, unknown, TData>,
export const useGetFileConfig = <TData = t.TFileConfig>(
config?: UseQueryOptions<t.TFileConfig, unknown, TData>,
): QueryObserverResult<TData, unknown> => {
return useQuery<t.FileConfig, unknown, TData>(
return useQuery<t.TFileConfig, unknown, TData>(
[QueryKeys.fileConfig],
() => dataService.getFileConfig(),
{

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, UseMutationResult } from '@tanstack/react-query';
import { dataService, QueryKeys, ResourceType } from 'librechat-data-provider';
import { useMutation, useQueryClient, UseMutationResult } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
/**
@ -84,9 +84,12 @@ export const useUpdateMCPServerMutation = (options?: {
// Update list cache
const listRes = queryClient.getQueryData<t.MCPServersListResponse>([QueryKeys.mcpServers]);
if (listRes) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type, ...oldServer } = listRes[variables.serverName!];
listRes[variables.serverName!] = { ...oldServer, ...updatedServer };
const oldServer = listRes[variables.serverName];
listRes[variables.serverName] = {
...updatedServer,
dbId: updatedServer.dbId ?? oldServer?.dbId,
consumeOnly: updatedServer.consumeOnly ?? oldServer?.consumeOnly,
};
queryClient.setQueryData<t.MCPServersListResponse>([QueryKeys.mcpServers], {
...listRes,

View file

@ -21,7 +21,9 @@ export class MediaSourceAppender {
}
public addBase64Data(base64Data: string) {
this.addData(Uint8Array.from(atob(base64Data), (char) => char.charCodeAt(0)).buffer);
this.addData(
Uint8Array.from(atob(base64Data), (char) => char.charCodeAt(0)).buffer as ArrayBuffer,
);
}
public addData(data: ArrayBuffer) {

View file

@ -2,6 +2,10 @@ import { Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { getMessageCacheIds, getMessagesConversationId } from '../cache';
/** `Constants` is a heterogeneous enum (`string | number`); annotate as
* `string` so the member is usable where a `string` field is expected. */
const NEW_CONVO: string = Constants.NEW_CONVO;
const message = (conversationId?: string | null): TMessage =>
({
messageId: 'message-id',
@ -22,8 +26,8 @@ describe('chat message cache helpers', () => {
it('mirrors new-chat messages into the generated conversation cache', () => {
expect(
getMessageCacheIds({
queryParam: Constants.NEW_CONVO,
conversationId: Constants.NEW_CONVO,
queryParam: NEW_CONVO,
conversationId: NEW_CONVO,
messages: [message('generated-convo-id')],
}),
).toEqual([Constants.NEW_CONVO, 'generated-convo-id']);

View file

@ -7,7 +7,7 @@ type MessageCacheIdsParams = {
messages: TMessage[];
};
function isConcreteConversationId(conversationId?: string | null) {
function isConcreteConversationId(conversationId?: string | null): conversationId is string {
return (
!!conversationId &&
conversationId !== Constants.NEW_CONVO &&

View file

@ -4,14 +4,20 @@ import { renderHook } from '@testing-library/react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TUser } from 'librechat-data-provider';
type CloudFrontRetryOptions = { getAuthorizationHeader: () => string | undefined };
const mockUseHasAccess = jest.fn();
const mockUseMCPServersQuery = jest.fn();
const mockUseMCPToolsQuery = jest.fn();
const mockInstallCloudFrontImageRetry = jest.fn(() => jest.fn());
const mockInstallCloudFrontImageRetry = jest.fn(
(_startupConfig: unknown, _options: CloudFrontRetryOptions): (() => void) =>
() =>
undefined,
);
const mockGetTokenHeader = jest.fn();
jest.mock('@librechat/client', () => ({
installCloudFrontImageRetry: (startupConfig: unknown, options: unknown) =>
installCloudFrontImageRetry: (startupConfig: unknown, options: CloudFrontRetryOptions) =>
mockInstallCloudFrontImageRetry(startupConfig, options),
}));

View file

@ -16,12 +16,20 @@
*/
import { useEffect } from 'react';
import { Tools } from 'librechat-data-provider';
import { renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import type {
TAttachment,
TFile,
TAttachmentMetadata,
TFilePreview,
} from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { TAttachment, TFilePreview } from 'librechat-data-provider';
import store from '~/store';
type AttachmentFixture = TFile & TAttachmentMetadata;
const mockUseFilePreview = jest.fn();
jest.mock('~/data-provider', () => ({
useFilePreview: (...args: unknown[]) => mockUseFilePreview(...args),
@ -34,19 +42,24 @@ const wrapper = ({ children }: { children: ReactNode }) => <RecoilRoot>{children
const messageId = 'msg-1';
const fileId = 'fid-1';
function makeAttachment(overrides: Partial<TAttachment> = {}): TAttachment {
function makeAttachment(overrides: Partial<AttachmentFixture> = {}): AttachmentFixture {
return {
user: 'user-1',
object: 'file',
bytes: 1024,
embedded: false,
usage: 0,
file_id: fileId,
filename: 'data.xlsx',
filepath: '/uploads/data.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
type: Tools.execute_code,
messageId,
toolCallId: 'tc-1',
text: null,
textFormat: null,
text: undefined,
textFormat: undefined,
status: 'pending',
...overrides,
} as unknown as TAttachment;
};
}
/** Read messageAttachmentsMap and bridge it out via a mutable ref. */
@ -261,7 +274,7 @@ describe('useAttachmentPreviewSync', () => {
textFormat: 'html',
},
});
const updated = ctx.map[messageId]?.[0] as TAttachment & { text?: string };
const updated = ctx.map[messageId]?.[0] as AttachmentFixture;
expect(updated.status).toBe('ready');
expect(updated.text).toBe('<table>final</table>');
expect(ctx.result.current.status).toBe('ready');
@ -277,7 +290,7 @@ describe('useAttachmentPreviewSync', () => {
previewError: 'parser-error',
},
});
const updated = ctx.map[messageId]?.[0] as TAttachment & { previewError?: string };
const updated = ctx.map[messageId]?.[0] as AttachmentFixture;
expect(updated.status).toBe('failed');
expect(updated.previewError).toBe('parser-error');
expect(ctx.result.current.status).toBe('failed');
@ -293,7 +306,7 @@ describe('useAttachmentPreviewSync', () => {
/* Map should be unchanged from the initial seed — no patch. */
const list = ctx.map[messageId] ?? [];
expect(list).toHaveLength(1);
expect((list[0] as TAttachment & { status?: string }).status).toBe('pending');
expect((list[0] as AttachmentFixture).status).toBe('pending');
});
it('reports isPolling true when the query is fetching and the gate is open', () => {
@ -336,7 +349,7 @@ describe('useAttachmentPreviewSync', () => {
});
const list = ctx.map[messageId] ?? [];
expect(list).toHaveLength(1);
const inserted = list[0] as TAttachment & { text?: string; textFormat?: string };
const inserted = list[0] as AttachmentFixture;
expect(inserted.file_id).toBe(fileId);
expect(inserted.status).toBe('ready');
expect(inserted.text).toBe('<table>resolved-on-reload</table>');
@ -361,7 +374,7 @@ describe('useAttachmentPreviewSync', () => {
});
const list = ctx.map[messageId] ?? [];
expect(list).toHaveLength(1);
const inserted = list[0] as TAttachment & { previewError?: string };
const inserted = list[0] as AttachmentFixture;
expect(inserted.status).toBe('failed');
expect(inserted.previewError).toBe('render-timeout');
});

View file

@ -83,7 +83,7 @@ export default function useDragHelpers() {
currentEndpoint,
agent?.provider,
);
const cfg = queryClient.getQueryData<t.FileConfig>([QueryKeys.fileConfig]);
const cfg = queryClient.getQueryData<t.TFileConfig>([QueryKeys.fileConfig]);
if (cfg) {
const mergedCfg = mergeFileConfig(cfg);
const endpointCfg = getEndpointFileConfig({

View file

@ -1,12 +1,12 @@
import React from 'react';
import { Provider, createStore } from 'jotai';
import { renderHook, act, waitFor } from '@testing-library/react';
import { RecoilRoot, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import { RecoilRoot, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { MCPServerDefinition } from '../useMCPServerManager';
import { ephemeralAgentByConvoId } from '~/store';
import { setTimestamp } from '~/utils/timestamps';
import { useMCPSelect } from '../useMCPSelect';
import { MCPServerDefinition } from '../useMCPServerManager';
// Mock dependencies
jest.mock('~/utils/timestamps', () => ({
@ -20,6 +20,7 @@ const createMCPServers = (serverNames: string[]): MCPServerDefinition[] => {
return serverNames.map((serverName) => ({
serverName,
config: {
type: 'sse',
url: 'http://mcp',
},
effectivePermissions: 15, // All permissions (VIEW=1, EDIT=2, DELETE=4, SHARE=8)

View file

@ -11,31 +11,39 @@
*/
import { useEffect } from 'react';
import { Tools } from 'librechat-data-provider';
import { renderHook } from '@testing-library/react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { TAttachment } from 'librechat-data-provider';
import store from '~/store';
type AttachmentFixture = TFile & TAttachmentMetadata;
jest.mock('~/hooks/useLocalize', () => () => (key: string) => key);
import useAttachments from '../useAttachments';
const messageId = 'msg-1';
function makeAttachment(overrides: Partial<TAttachment> = {}): TAttachment {
function makeAttachment(overrides: Partial<AttachmentFixture> = {}): AttachmentFixture {
return {
user: 'user-1',
object: 'file',
bytes: 1024,
embedded: false,
usage: 0,
file_id: 'fid-1',
filename: 'data.xlsx',
filepath: '/uploads/data.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
type: Tools.execute_code,
messageId,
toolCallId: 'tc-1',
text: null,
textFormat: null,
text: undefined,
textFormat: undefined,
status: 'pending',
...overrides,
} as unknown as TAttachment;
};
}
function setup({
@ -91,7 +99,7 @@ describe('useAttachments', () => {
* immediate-persist snapshot; the polling layer fetched the
* resolved record and inserted it into messageAttachmentsMap; the
* merge here must surface the resolved fields to the renderer. */
const db = makeAttachment({ status: 'pending', text: null, textFormat: null });
const db = makeAttachment({ status: 'pending', text: undefined, textFormat: undefined });
const live = makeAttachment({
status: 'ready',
text: '<table>resolved</table>',
@ -102,10 +110,7 @@ describe('useAttachments', () => {
liveMap: { [messageId]: [live] },
});
expect(result.current.attachments).toHaveLength(1);
const merged = result.current.attachments[0] as TAttachment & {
text?: string;
textFormat?: string;
};
const merged = result.current.attachments[0] as AttachmentFixture;
expect(merged.status).toBe('ready');
expect(merged.text).toBe('<table>resolved</table>');
expect(merged.textFormat).toBe('html');
@ -123,7 +128,7 @@ describe('useAttachments', () => {
/* DB attachments are the authoritative list for THIS message — a
* live entry without a matching file_id must NOT bleed in. */
expect(result.current.attachments).toHaveLength(1);
expect((result.current.attachments[0] as TAttachment).file_id).toBe('fid-A');
expect((result.current.attachments[0] as TAttachment).status).toBe('pending');
expect((result.current.attachments[0] as AttachmentFixture).file_id).toBe('fid-A');
expect((result.current.attachments[0] as AttachmentFixture).status).toBe('pending');
});
});

View file

@ -56,7 +56,7 @@ export default function useCopyToClipboard({
if (content) {
messageText = content.reduce((acc, curr, i) => {
if (curr.type === ContentTypes.TEXT) {
const text = typeof curr.text === 'string' ? curr.text : curr.text.value;
const text = typeof curr.text === 'string' ? curr.text : (curr.text?.value ?? '');
return acc + text + (i === content.length - 1 ? '' : '\n');
}
return acc;

View file

@ -20,6 +20,17 @@ interface DeduplicatedSource {
metadata?: any;
}
/**
* The `file_search` attachment is typed as {@link SearchResultData} in the
* shared schema, but at runtime the agent file-search tool emits a
* `{ sources: FileSource[] }` payload. Read the sources defensively.
*/
type FileSearchAttachmentData = SearchResultData & { sources?: FileSource[] };
function getFileSearchSources(data: FileSearchAttachmentData): FileSource[] {
return Array.isArray(data.sources) ? data.sources : [];
}
/**
* Hook that creates a map of turn numbers to SearchResultData from web search and agent file search attachments
* @param attachments Array of attachment metadata
@ -42,7 +53,7 @@ export function useSearchResultsByTurn(attachments?: TAttachment[]) {
// Handle agent file search attachments (following web search pattern)
if (attachment.type === Tools.file_search && attachment[Tools.file_search]) {
const sources = attachment[Tools.file_search].sources;
const sources = getFileSearchSources(attachment[Tools.file_search]);
// Deduplicate sources by fileId and merge pages
const deduplicatedSources = new Map<string, DeduplicatedSource>();

View file

@ -14,33 +14,45 @@
* unrelated citations both show up.
*/
import { renderHook, act } from '@testing-library/react';
import { Tools } from 'librechat-data-provider';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { QueryClient } from '@tanstack/react-query';
import { Tools } from 'librechat-data-provider';
import { renderHook, act } from '@testing-library/react';
import type {
TFile,
TAttachment,
EventSubmission,
TAttachmentMetadata,
} from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { TAttachment, EventSubmission } from 'librechat-data-provider';
import useAttachmentHandler from '../useAttachmentHandler';
import store from '~/store';
type AttachmentFixture = TFile & TAttachmentMetadata;
const wrapper = ({ children }: { children: ReactNode }) => <RecoilRoot>{children}</RecoilRoot>;
const submission = {} as EventSubmission;
const messageId = 'msg-1';
function makeAttachment(overrides: Partial<TAttachment>): TAttachment {
function makeAttachment(overrides: Partial<AttachmentFixture>): AttachmentFixture {
return {
user: 'user-1',
object: 'file',
bytes: 1024,
embedded: false,
usage: 0,
file_id: 'fid-1',
filename: 'data.xlsx',
filepath: '/uploads/data.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
type: Tools.execute_code,
messageId,
toolCallId: 'tc-1',
text: null,
textFormat: null,
text: undefined,
textFormat: undefined,
status: 'pending',
...overrides,
} as unknown as TAttachment;
};
}
/* Co-mount the handler and a reader of the messageAttachmentsMap atom in
@ -162,7 +174,7 @@ describe('useAttachmentHandler upsert-by-file_id', () => {
* downgrade it. Otherwise the chip flickers back to "pending" and
* polling restarts until the lazy sweep catches up. (Codex P1.) */
const ctx = setup();
ctx.handle(makeAttachment({ status: 'pending', text: null }));
ctx.handle(makeAttachment({ status: 'pending', text: undefined }));
ctx.handle({
file_id: 'fid-1',
messageId,
@ -171,7 +183,7 @@ describe('useAttachmentHandler upsert-by-file_id', () => {
textFormat: 'html',
} as unknown as TAttachment);
/* Phase-1 replay arrives last (finalHandler at stream end). */
ctx.handle(makeAttachment({ status: 'pending', text: null }));
ctx.handle(makeAttachment({ status: 'pending', text: undefined }));
expect(ctx.list).toHaveLength(1);
expect(ctx.list[0]).toMatchObject({
status: 'ready',

View file

@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react';
import { RecoilRoot, useRecoilCallback } from 'recoil';
import { renderHook, act } from '@testing-library/react';
import {
Constants,
StepTypes,
@ -17,8 +17,20 @@ import type {
SubagentUpdateEvent,
Agents,
} from 'librechat-data-provider';
import useStepHandler from '~/hooks/SSE/useStepHandler';
import { subagentProgressByToolCallId } from '~/store/subagents';
import useStepHandler from '~/hooks/SSE/useStepHandler';
/** `Constants` is a heterogeneous enum (`string | number`); annotate as
* `string` so the member is usable where a `string` field is expected. */
const USE_PRELIM_RESPONSE_MESSAGE_ID: string = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
const getToolCallName = (part?: TMessageContentParts): string | undefined => {
if (part?.type !== ContentTypes.TOOL_CALL) {
return undefined;
}
const { tool_call: toolCall } = part;
return 'name' in toolCall ? toolCall.name : undefined;
};
type TSubmissionForTest = {
userMessage: TMessage;
@ -247,7 +259,7 @@ describe('useStepHandler', () => {
const firstRunStep = createToolCallRunStep({
id: 'step-oauth-eli',
runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
runId: USE_PRELIM_RESPONSE_MESSAGE_ID,
index: 0,
stepDetails: {
type: StepTypes.TOOL_CALLS,
@ -263,7 +275,7 @@ describe('useStepHandler', () => {
});
const secondRunStep = createToolCallRunStep({
id: 'step-oauth-vespa',
runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
runId: USE_PRELIM_RESPONSE_MESSAGE_ID,
index: 1,
stepDetails: {
type: StepTypes.TOOL_CALLS,
@ -291,8 +303,8 @@ describe('useStepHandler', () => {
const responseMsg = currentMessages.find((m) => !m.isCreatedByUser);
expect(responseMsg?.content).toHaveLength(2);
expect(responseMsg?.content?.[0]?.tool_call?.name).toBe(`oauth${Constants.mcp_delimiter}ELI`);
expect(responseMsg?.content?.[1]?.tool_call?.name).toBe(
expect(getToolCallName(responseMsg?.content?.[0])).toBe(`oauth${Constants.mcp_delimiter}ELI`);
expect(getToolCallName(responseMsg?.content?.[1])).toBe(
`oauth${Constants.mcp_delimiter}Vespa`,
);
});
@ -314,7 +326,7 @@ describe('useStepHandler', () => {
event: StepEvents.ON_RUN_STEP,
data: createToolCallRunStep({
id: 'step-oauth-eli',
runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
runId: USE_PRELIM_RESPONSE_MESSAGE_ID,
index: 0,
stepDetails: {
type: StepTypes.TOOL_CALLS,
@ -336,7 +348,7 @@ describe('useStepHandler', () => {
event: StepEvents.ON_RUN_STEP,
data: createToolCallRunStep({
id: 'step-oauth-vespa',
runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
runId: USE_PRELIM_RESPONSE_MESSAGE_ID,
index: 1,
stepDetails: {
type: StepTypes.TOOL_CALLS,
@ -358,7 +370,7 @@ describe('useStepHandler', () => {
event: StepEvents.ON_RUN_STEP,
data: createRunStep({
id: 'step-message',
runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
runId: USE_PRELIM_RESPONSE_MESSAGE_ID,
index: 0,
}),
},

View file

@ -20,14 +20,14 @@ import type {
} from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { AnnounceOptions } from '~/common';
import { MESSAGE_UPDATE_INTERVAL } from '~/common';
import { subagentProgressByToolCallId } from '~/store';
import {
foldSubagentEvent,
foldSubagentEventIntoTicker,
initSubagentAggregatorState,
initSubagentTickerState,
} from '~/utils/subagentContent';
import { subagentProgressByToolCallId } from '~/store';
import { MESSAGE_UPDATE_INTERVAL } from '~/common';
type TUseStepHandler = {
announcePolite: (options: AnnounceOptions) => void;
@ -66,10 +66,14 @@ type AllContentTypes =
const isOAuthToolCallName = (name?: string) =>
typeof name === 'string' && name.startsWith(`oauth${Constants.mcp_delimiter}`);
const isOAuthToolCallContent = (part?: Partial<TMessageContentParts>) =>
part?.type === ContentTypes.TOOL_CALL &&
'tool_call' in part &&
isOAuthToolCallName(part.tool_call?.name);
const isOAuthToolCallContent = (part?: Partial<TMessageContentParts>) => {
if (part?.type !== ContentTypes.TOOL_CALL || !('tool_call' in part)) {
return false;
}
const { tool_call: toolCall } = part;
const name = toolCall != null && 'name' in toolCall ? toolCall.name : undefined;
return isOAuthToolCallName(name);
};
export default function useStepHandler({
setMessages,

View file

@ -105,8 +105,11 @@ describe('useRum', () => {
});
it('initializes proxy RUM with the LibreChat bearer token for same-origin ingest', async () => {
const fetchMock = jest.fn(() => Promise.resolve({}));
window.fetch = fetchMock;
const fetchMock = jest.fn(
(_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
Promise.resolve({ ok: true, status: 200 } as Response),
);
window.fetch = Object.assign(fetchMock, { preconnect: () => undefined });
mockUseGetStartupConfig.mockReturnValue({
data: {
rum: {

View file

@ -76,7 +76,7 @@ function ensureRumProxyAuth(proxyUrl: string): void {
}
const originalFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const patchedFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
if (rumProxyToken && isRumProxyRequest(input, proxyUrl)) {
const [authorizedInput, authorizedInit] = withAuthorization(input, init, rumProxyToken);
return originalFetch(authorizedInput, authorizedInit);
@ -84,6 +84,7 @@ function ensureRumProxyAuth(proxyUrl: string): void {
return originalFetch(input, init);
};
window.fetch = Object.assign(patchedFetch, { preconnect: window.fetch.preconnect });
rumProxyFetchPatched = true;
}

View file

@ -1063,6 +1063,7 @@
"com_ui_file_token_limit": "File Token Limit",
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
"com_ui_files": "Files",
"com_ui_files_count_size": "{{0}} Files ({{1}}KB)",
"com_ui_filter_mcp_servers": "Filter MCP servers by name",
"com_ui_filter_prompts": "Filter Prompts",
"com_ui_filter_prompts_name": "Filter prompts by name",
@ -1261,6 +1262,7 @@
"com_ui_model_parameters_reset": "Model Parameters have been reset.",
"com_ui_model_selected": "{{0}} selected",
"com_ui_model_spec": "Model Spec",
"com_ui_more_count": "{{0}} more",
"com_ui_more_info": "More info",
"com_ui_more_options": "More options",
"com_ui_my_prompts": "My Prompts",

View file

@ -323,7 +323,7 @@ describe('deleteData', () => {
it('removes the matching item from its page', () => {
const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha'), makeItem('b', 'Beta')] }]);
const result = deleteData<Page, Item>(data, 'items', (page) =>
const result = deleteData<Page, InfiniteData<Page>>(data, 'items', (page) =>
page.items.findIndex((i) => i.id === 'a'),
);
@ -334,7 +334,7 @@ describe('deleteData', () => {
it('leaves data unchanged when item is not found', () => {
const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha')] }]);
const result = deleteData<Page, Item>(data, 'items', (page) =>
const result = deleteData<Page, InfiniteData<Page>>(data, 'items', (page) =>
page.items.findIndex((i) => i.id === 'z'),
);

View file

@ -7,6 +7,7 @@ describe('ConversationTag Utilities', () => {
beforeEach(() => {
conversations = [
{
_id: 'tag-id-saved',
tag: 'saved',
count: 1,
position: 0,
@ -16,6 +17,7 @@ describe('ConversationTag Utilities', () => {
user: 'user1',
},
{
_id: 'tag-id-1',
tag: 'tag1',
count: 1,
position: 1,
@ -25,6 +27,7 @@ describe('ConversationTag Utilities', () => {
user: 'user1',
},
{
_id: 'tag-id-2',
tag: 'tag2',
count: 20,
position: 2,
@ -34,6 +37,7 @@ describe('ConversationTag Utilities', () => {
user: 'user1',
},
{
_id: 'tag-id-3',
tag: 'tag3',
count: 30,
position: 3,
@ -43,6 +47,7 @@ describe('ConversationTag Utilities', () => {
user: 'user1',
},
{
_id: 'tag-id-4',
tag: 'tag4',
count: 40,
position: 4,
@ -52,6 +57,7 @@ describe('ConversationTag Utilities', () => {
user: 'user1',
},
{
_id: 'tag-id-5',
tag: 'tag5',
count: 50,
position: 5,
@ -175,6 +181,7 @@ describe('ConversationTag Utilities', () => {
conversations,
{ tag: 'newtag', description: 'newDescription' },
{
_id: 'tag-id-newtag',
tag: 'newtag',
description: 'newDescription',
position: 1,
@ -208,6 +215,7 @@ describe('ConversationTag Utilities', () => {
[],
{ tag: 'newtag', description: 'newDescription' },
{
_id: 'tag-id-saved',
tag: 'saved',
description: 'newDescription',
position: 0,

View file

@ -45,7 +45,9 @@ export function supportsClientResize(): boolean {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
return !!(ctx && ctx.drawImage && canvas.toBlob);
return (
ctx != null && typeof ctx.drawImage === 'function' && typeof canvas.toBlob === 'function'
);
} catch {
return false;
}

View file

@ -28,10 +28,8 @@
"include": [
"src/**/*",
"test/**/*",
"../e2e/**/*",
"test/setupTests.js",
"env.d.ts",
"../config/translations/**/*.ts",
"../packages/client/src/hooks/useDelayedRender.tsx",
"../packages/client/src/components/InfoHoverCard.tsx"
]

View file

@ -1,18 +1,19 @@
import type { AxiosResponse } from 'axios';
import type { TFileConfig } from './file-config';
import type * as t from './types';
import * as permissions from './accessPermissions';
import * as endpoints from './api-endpoints';
import * as a from './types/assistants';
import * as ag from './types/agents';
import * as m from './types/mutations';
import * as q from './types/queries';
import * as f from './types/files';
import * as sk from './types/skills';
import * as mcp from './types/mcpServers';
import * as a from './types/assistants';
import * as m from './types/mutations';
import * as ag from './types/agents';
import * as q from './types/queries';
import * as sk from './types/skills';
import * as f from './types/files';
import * as config from './config';
import request from './request';
import * as s from './schemas';
import * as r from './roles';
import * as permissions from './accessPermissions';
export function revokeUserKey(name: string): Promise<unknown> {
return request.delete(endpoints.revokeUserKey(name));
@ -429,7 +430,7 @@ export const getAgentFiles = (agentId: string): Promise<f.TFile[]> => {
return request.get(endpoints.agentFiles(agentId));
};
export const getFileConfig = (): Promise<f.FileConfig> => {
export const getFileConfig = (): Promise<TFileConfig> => {
return request.get(`${endpoints.files()}/config`);
};