diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 20afa22e..e4715ec2 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1588,6 +1588,7 @@ list stays reviewable. | `settings/PersonalAccessTokens.tsx` | `shadcn/no-restyle` | Token scope chips are identifiers, so the `neutral` Badge is set in mono (`font-mono`). One disable. | | `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | A step's state changes (keys and values the app serialised) are `neutral` Badges set in mono (`font-mono`), like token scopes. One disable. | | `settings/traces/TraceChips.tsx` | `shadcn/no-restyle` | Trace stat chips (durations, counts) use tabular figures so they don't jitter between rows (`tabular-nums` on Badge). One disable. | +| `connectors/ConnectorSetupNotice.tsx` | `shadcn/no-restyle` | The setup-guide link inside the needs-setup warning Alert keeps the Alert's status colour (`text-current` on `link inline`). One disable. | | `modals/MCPServerModal.tsx` | `shadcn/no-restyle` | The authorization link inside the test-result Alert keeps the Alert's status colour (`text-current` on `link inline`). One disable. | | `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom readout between − and + is a `link inline` Button on the `bg-black/70` overlay; it keeps the overlay's white 12px regular text (`text-xs font-normal text-current`). One disable, beside the two zoom-button ones above. | | `conversation/SharedConversation.tsx` | `shadcn/no-restyle` | The "DocsGPT" link sits in the `/share/:id` page's regular-weight byline (`font-normal`). One disable. | diff --git a/frontend/src/admin/Connectors.test.tsx b/frontend/src/admin/Connectors.test.tsx index 265a4315..5643546f 100644 --- a/frontend/src/admin/Connectors.test.tsx +++ b/frontend/src/admin/Connectors.test.tsx @@ -16,6 +16,9 @@ vi.mock('../api/services/connectorsService', () => ({ }, })); +import actionToastReducer, { + selectActionToast, +} from '../notifications/actionToastSlice'; import { prefSlice } from '../preferences/preferenceSlice'; import Connectors from './Connectors'; @@ -79,10 +82,17 @@ describe('Admin Connectors', () => { container.remove(); }); - const render = async () => { - const store = configureStore({ - reducer: { preference: prefSlice.reducer }, + let store: ReturnType; + const makeStore = () => + configureStore({ + reducer: { + preference: prefSlice.reducer, + actionToast: actionToastReducer, + }, }); + + const render = async () => { + store = makeStore(); await act(async () => { root.render( @@ -111,7 +121,9 @@ describe('Admin Connectors', () => { it('warns when credentials use the default encryption key', async () => { getAdmin.mockResolvedValue(payload({ default_encryption_key: true })); await render(); - expect(container.textContent).toContain('admin.connectors.defaultKey'); + expect(container.textContent).toContain( + 'Set ENCRYPTION_SECRET_KEY before connecting services.', + ); expect(container.textContent).toContain('docsgpt connectors reencrypt'); }); @@ -138,7 +150,7 @@ describe('Admin Connectors', () => { ).toBe('false'); }); - it('shows an error and keeps the page when a save fails', async () => { + it('reports a failed save in a toast and keeps the page', async () => { getAdmin.mockResolvedValue(payload()); updateAdmin.mockResolvedValue({ success: false }); await render(); @@ -146,7 +158,10 @@ describe('Admin Connectors', () => { container.querySelector('#allow-custom-mcp')!.click(), ); expect(updateAdmin).toHaveBeenCalledWith({ allow_custom_mcp: false }, null); - expect(container.textContent).toContain('Could not save the change.'); + expect(selectActionToast(store.getState())).toMatchObject({ + variant: 'destructive', + message: 'Could not save the change.', + }); expect(container.querySelectorAll('tbody tr')).toHaveLength(2); }); diff --git a/frontend/src/admin/Connectors.tsx b/frontend/src/admin/Connectors.tsx index 5a1d989e..40ead063 100644 --- a/frontend/src/admin/Connectors.tsx +++ b/frontend/src/admin/Connectors.tsx @@ -1,14 +1,18 @@ -import { ExternalLink, TriangleAlert } from 'lucide-react'; +import { ExternalLink, Info, TriangleAlert } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import connectorsService from '../api/services/connectorsService'; import CopyButton from '../components/CopyButton'; +import PageToolbar from '../components/PageToolbar'; import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert'; import { Badge } from '../components/ui/badge'; import { Button } from '../components/ui/button'; import { Card } from '../components/ui/card'; +import { + DescriptionItem, + DescriptionList, +} from '../components/ui/description-list'; import { LoadingState } from '../components/ui/loading-state'; import { Modal } from '../components/ui/modal'; import { SectionHeader } from '../components/ui/section-header'; @@ -31,6 +35,7 @@ import { TableRow, } from '../components/ui/table'; import ConnectorIcon from '../connectors/ConnectorIcon'; +import { showActionToast } from '../notifications/actionToastSlice'; import { selectToken } from '../preferences/preferenceSlice'; import { LoadError, fmtNumber } from './AdminUI'; @@ -105,23 +110,22 @@ function SetupGuide({
-
    + {connector.required_settings.map((setting) => ( -
  • {setting.name}} > - {setting.name} {setting.set ? 'Set' : 'Missing'} -
  • + ))} -
+
{connector.key === 'google_drive' && ( - + Publish the Google OAuth app (or use an internal Workspace app). Apps left in Testing get refresh tokens that expire after seven @@ -152,11 +156,10 @@ function SetupGuide({ * server. English only, like the rest of the admin pages. */ export default function Connectors() { - const { t } = useTranslation(); + const dispatch = useDispatch(); const token = useSelector(selectToken); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [saveError, setSaveError] = useState(false); const [guide, setGuide] = useState(null); const load = useCallback(async () => { @@ -178,13 +181,19 @@ export default function Connectors() { policies?: Record; allow_custom_mcp?: boolean; }) => { - setSaveError(false); + const failed = () => + dispatch( + showActionToast({ + variant: 'destructive', + message: 'Could not save the change.', + }), + ); try { const next = await connectorsService.updateAdmin(body, token); if (next?.success) setData(next); - else setSaveError(true); + else failed(); } catch { - setSaveError(true); + failed(); } }; @@ -194,17 +203,14 @@ export default function Connectors() { return (
-

- Choose which connectors members can use and whose account a shared tool - runs with. A connector that still needs server settings starts turned - off and is hidden from members; it turns on once its settings are in - place, unless you switch it off. -

+ {data.default_encryption_key && ( - {t('admin.connectors.defaultKey')} + + Set ENCRYPTION_SECRET_KEY before connecting services. + Stored credentials are encrypted with ENCRYPTION_SECRET_KEY, which still has its public default value. Set your own, keep the old one @@ -216,12 +222,6 @@ export default function Connectors() { )} - {saveError && ( - - - Could not save the change. - - )}
- -

- Register these with each provider's OAuth app. -

+
diff --git a/frontend/src/components/ConnectorAuth.tsx b/frontend/src/components/ConnectorAuth.tsx index 2fad975a..cc98a9bd 100644 --- a/frontend/src/components/ConnectorAuth.tsx +++ b/frontend/src/components/ConnectorAuth.tsx @@ -9,30 +9,35 @@ import { isTrustedConnectorMessage } from '../utils/connectorAuthUtils'; import { Alert, AlertDescription } from './ui/alert'; import { Button } from './ui/button'; -interface ConnectorAuthProps { +interface ConnectorAuthOptions { provider: string; /** Reconnect this connection (the same account) rather than add one. */ connectionId?: string; onSuccess: (data: { connection_id: string; user_email: string }) => void; onError: (error: string) => void; +} + +interface ConnectorAuthProps extends ConnectorAuthOptions { label?: string; + /** The service's glyph on the sign-in button (e.g. a `ConnectorIcon`). */ + icon?: React.ReactNode; isConnected?: boolean; userEmail?: string; onDisconnect?: () => void; errorMessage?: string; } -const ConnectorAuth: React.FC = ({ +/** + * The OAuth pop-up sign-in: returns the function to call from a click. It + * opens the pop-up inside the gesture, points it at the provider and reports + * the connection it created (or an error) once the callback page answers. + */ +export function useConnectorAuth({ provider, connectionId, onSuccess, onError, - label, - isConnected = false, - userEmail = '', - onDisconnect, - errorMessage, -}) => { +}: ConnectorAuthOptions): () => void { const { t } = useTranslation(); const token = useSelector(selectToken); const completedRef = useRef(false); @@ -209,6 +214,29 @@ const ConnectorAuth: React.FC = ({ }; }, []); + return handleAuth; +} + +const ConnectorAuth: React.FC = ({ + provider, + connectionId, + onSuccess, + onError, + label, + icon, + isConnected = false, + userEmail = '', + onDisconnect, + errorMessage, +}) => { + const { t } = useTranslation(); + const handleAuth = useConnectorAuth({ + provider, + connectionId, + onSuccess, + onError, + }); + return ( <> {errorMessage && ( @@ -242,12 +270,7 @@ const ConnectorAuth: React.FC = ({ ) : ( )} diff --git a/frontend/src/components/FilePicker.tsx b/frontend/src/components/FilePicker.tsx index 6f191bcd..3b36a89b 100644 --- a/frontend/src/components/FilePicker.tsx +++ b/frontend/src/components/FilePicker.tsx @@ -13,6 +13,8 @@ import { formatDateTime } from '../utils/dateTimeUtils'; import { useDispatch, useSelector } from 'react-redux'; import connectorsService from '../api/services/connectorsService'; import ConnectorAuth from '../components/ConnectorAuth'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { connectorIconKey } from '../connectors/i18n'; import { loadConnectors, selectConnections, @@ -453,7 +455,7 @@ export const FilePicker: React.FC = ({ value={activeConnectionId ?? undefined} onValueChange={switchAccount} > - + @@ -472,6 +474,9 @@ export const FilePicker: React.FC = ({ label={t('filePicker.connectTo', { provider: getProviderConfig(provider).displayName, })} + icon={ + + } onSuccess={(data) => { setAuthError(''); dispatch(loadConnectors({ token })); diff --git a/frontend/src/components/GoogleDrivePicker.tsx b/frontend/src/components/GoogleDrivePicker.tsx index 1c85c326..5d0b7818 100644 --- a/frontend/src/components/GoogleDrivePicker.tsx +++ b/frontend/src/components/GoogleDrivePicker.tsx @@ -17,6 +17,7 @@ import { selectConnections, } from '../connectors/connectorsSlice'; import type { AppDispatch } from '../store'; +import ConnectorIcon from '../connectors/ConnectorIcon'; import ConnectorAuth from './ConnectorAuth'; import SkeletonLoader from './SkeletonLoader'; import { Button } from './ui/button'; @@ -239,6 +240,7 @@ const GoogleDrivePicker: React.FC = ({ } onSuccess={(data) => { setAuthError(''); dispatch(loadConnectors({ token })); diff --git a/frontend/src/components/message-input/ToolsTrigger.tsx b/frontend/src/components/message-input/ToolsTrigger.tsx index 607ba0f9..f41c04b4 100644 --- a/frontend/src/components/message-input/ToolsTrigger.tsx +++ b/frontend/src/components/message-input/ToolsTrigger.tsx @@ -1,5 +1,6 @@ import { ArrowRight, Wrench } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; import { MultiSelectPopover, @@ -40,16 +41,16 @@ export default function ToolsTrigger({ footer={ } diff --git a/frontend/src/connectors/ConnectWizard.test.tsx b/frontend/src/connectors/ConnectWizard.test.tsx index cf04e8e6..18309962 100644 --- a/frontend/src/connectors/ConnectWizard.test.tsx +++ b/frontend/src/connectors/ConnectWizard.test.tsx @@ -26,22 +26,14 @@ vi.mock('../api/services/connectorsService', () => ({ default: service })); // The OAuth popup is covered by ConnectorAuth's own tests; here it only has // to report a finished sign-in. vi.mock('../components/ConnectorAuth', () => ({ - default: ({ - onSuccess, - label, - }: { - onSuccess: (data: { connection_id: string; user_email: string }) => void; - label: string; - }) => ( - - ), + useConnectorAuth: + ({ + onSuccess, + }: { + onSuccess: (data: { connection_id: string; user_email: string }) => void; + }) => + () => + onSuccess({ connection_id: 'conn-drive', user_email: 'a@example.com' }), })); vi.mock('../components/FilePicker', () => ({ FilePicker: ({ diff --git a/frontend/src/connectors/ConnectWizard.tsx b/frontend/src/connectors/ConnectWizard.tsx index 3cc1c23c..b3f648e1 100644 --- a/frontend/src/connectors/ConnectWizard.tsx +++ b/frontend/src/connectors/ConnectWizard.tsx @@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom'; import { envVar } from '@/env'; import connectorsService from '../api/services/connectorsService'; -import ConnectorAuth from '../components/ConnectorAuth'; +import { useConnectorAuth } from '../components/ConnectorAuth'; import { FilePicker } from '../components/FilePicker'; import GoogleDrivePicker from '../components/GoogleDrivePicker'; import { @@ -37,6 +37,7 @@ import { setSelectedDocs, } from '../preferences/preferenceSlice'; import type { AppDispatch } from '../store'; +import { formatCount } from '../utils/dateTimeUtils'; import ConnectorIcon from './ConnectorIcon'; import CredentialForm, { credentialsComplete } from './CredentialForm'; import { loadConnectors, selectConnections } from './connectorsSlice'; @@ -134,6 +135,14 @@ export default function ConnectWizard({ setStep(canSync ? 'setup' : 'done'); }; + const startSignIn = useConnectorAuth({ + provider: connector.key, + connectionId: + mode === 'reconnect' ? (connectionId ?? undefined) : undefined, + onSuccess: (data) => afterSignIn(data.connection_id), + onError: setError, + }); + const submitCredentials = async () => { setPending(true); setError(''); @@ -253,9 +262,11 @@ export default function ConnectWizard({ }); const sourcesText = t('settings.connectors.wizard.sourcesCount', { count: sources.length, + formatted: formatCount(sources.length), }); const toolsText = t('settings.connectors.wizard.toolsCount', { count: toolCount, + formatted: formatCount(toolCount), }); const countsLine = sources.length && toolCount @@ -289,20 +300,7 @@ export default function ConnectWizard({ {error} )} - {connector.auth_kind === 'oauth' ? ( - afterSignIn(data.connection_id)} - onError={setError} - /> - ) : ( + {connector.auth_kind === 'oauth' ? null : ( {t('settings.connectors.wizard.toolsHeading', { count: toolCount, + formatted: formatCount(toolCount), })} @@ -433,7 +432,17 @@ export default function ConnectWizard({ const footer = step === 'signin' ? ( - connector.auth_kind === 'oauth' ? undefined : ( + connector.auth_kind === 'oauth' ? ( + + ) : ( - + onReconnect(detail)} > @@ -275,11 +284,13 @@ function AccountSection({ } /> {detail.sources.length === 0 ? ( -

- {t('settings.connectors.detail.noSources')} -

+ ) : ( - + {detail.sources.map((source) => ( {detail.tools.map((tool) => (
-
- - {tool.display_name} - - - {tool.status - ? t('settings.connectors.detail.toolOn') - : t('settings.connectors.detail.toolOff')} - -
- + + {tool.status + ? t('settings.connectors.detail.toolOn') + : t('settings.connectors.detail.toolOff')} + + } + /> +
))}
@@ -420,10 +437,23 @@ export default function ConnectionDrawer({ const confirmDisconnect = () => { if (!toDisconnect) return; - connectorsService.disconnect(toDisconnect.id, token).finally(() => { - setToDisconnect(null); - refresh(); - }); + connectorsService + .disconnect(toDisconnect.id, token) + .then((data) => { + if (!data?.success) throw new Error('disconnect failed'); + }) + .catch(() => + dispatch( + showActionToast({ + variant: 'destructive', + message: t('settings.connectors.disconnect.failed'), + }), + ), + ) + .finally(() => { + setToDisconnect(null); + refresh(); + }); }; const reconnect = (detail: ConnectionDetail) => { @@ -452,8 +482,8 @@ export default function ConnectionDrawer({ ? { variant: 'success', message: t('settings.connectors.detail.refreshed', { - added: data.added?.length ?? 0, - removed: data.removed?.length ?? 0, + added: formatCount(data.added?.length ?? 0), + removed: formatCount(data.removed?.length ?? 0), }), } : { @@ -474,11 +504,12 @@ export default function ConnectionDrawer({
-
+ {/* pr-12 keeps the header clear of the close X. */} +
@@ -489,14 +520,14 @@ export default function ConnectionDrawer({

-

+ {connectorDescription(t, connector)} -

+ {connector.publisher === 'custom' && ( - + {t('settings.connectors.unverified')} @@ -504,43 +535,7 @@ export default function ConnectionDrawer({ )} {connector.needs_setup && ( - - - - {t('settings.connectors.status.needsAdminSetup')} - - -
- {connector.missing_settings.length > 0 ? ( - <> - {t('settings.connectors.setupSettings')} - - {connector.missing_settings.join(', ')} - - - ) : ( - {t('settings.connectors.askAdmin')} - )} - {connector.docs_url && ( - - )} -
-
-
+ )}
@@ -575,6 +570,7 @@ export default function ConnectionDrawer({ + )} +
+ + + ); +} diff --git a/frontend/src/connectors/ToolPermissions.tsx b/frontend/src/connectors/ToolPermissions.tsx index c7b05cff..6d386837 100644 --- a/frontend/src/connectors/ToolPermissions.tsx +++ b/frontend/src/connectors/ToolPermissions.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import connectorsService from '../api/services/connectorsService'; import { Card } from '../components/ui/card'; @@ -12,6 +12,7 @@ import { SelectTrigger, SelectValue, } from '../components/ui/select'; +import { showActionToast } from '../notifications/actionToastSlice'; import { selectToken } from '../preferences/preferenceSlice'; import type { ActionPermission, @@ -24,20 +25,24 @@ const PERMISSIONS: ActionPermission[] = ['always', 'ask', 'off']; /** * One tool's actions, grouped Read and Write, each with Always allow / * Needs approval / Off. Changes save immediately; a failed save puts the - * previous choice back. + * previous choice back and says so. */ export default function ToolPermissions({ connectionId, tool, onChange, readOnly = false, + variant = 'outline', }: { connectionId: string; tool: ConnectionTool; onChange?: (tool: ConnectionTool) => void; readOnly?: boolean; + /** The panel surface: `outline` in a modal, `subtle` in the drawer. */ + variant?: 'outline' | 'subtle'; }) { const { t } = useTranslation(); + const dispatch = useDispatch(); const token = useSelector(selectToken); const [actions, setActions] = useState(tool.actions); @@ -53,7 +58,15 @@ export default function ToolPermissions({ if (!data?.success) throw new Error('save failed'); onChange?.(data.tool); }) - .catch(() => setActions(previous)); + .catch(() => { + setActions(previous); + dispatch( + showActionToast({ + variant: 'destructive', + message: t('settings.connectors.permission.saveFailed'), + }), + ); + }); }; const groups = (['read', 'write'] as const) @@ -64,7 +77,7 @@ export default function ToolPermissions({ .filter((group) => group.actions.length > 0); return ( - + {groups.map((group) => (
export default function useConnectorLauncher({ onConnected, }: { onConnected?: () => void } = {}) { + const { t } = useTranslation(); const dispatch = useDispatch(); const navigate = useNavigate(); const token = useSelector(selectToken); @@ -80,26 +83,34 @@ export default function useConnectorLauncher({ if (connector.key === 'custom_openapi') { // An OpenAPI tool starts empty; its spec import is today's API Tool // screen on the Tools page. - const response = await userService.getAvailableTools(token); - const data = await response.json(); - const tool = (data.data as AvailableToolType[] | undefined)?.find( - (candidate) => candidate.name === 'api_tool', - ); - if (!tool) return; - const created = await userService.createTool( - { - name: tool.name, - displayName: tool.displayName, - description: tool.description, - config: {}, - actions: tool.actions, - status: true, - }, - token, - ); - const body = await created.json(); - if (body?.id) { + try { + const response = await userService.getAvailableTools(token); + const data = await response.json(); + const tool = (data.data as AvailableToolType[] | undefined)?.find( + (candidate) => candidate.name === 'api_tool', + ); + if (!tool) throw new Error('api_tool is not available'); + const created = await userService.createTool( + { + name: tool.name, + displayName: tool.displayName, + description: tool.description, + config: {}, + actions: tool.actions, + status: true, + }, + token, + ); + const body = await created.json(); + if (!body?.id) throw new Error('create failed'); navigate('/settings/tools', { state: { openToolId: body.id } }); + } catch { + dispatch( + showActionToast({ + variant: 'destructive', + message: t('settings.connectors.createFailed'), + }), + ); } return; } @@ -110,7 +121,7 @@ export default function useConnectorLauncher({ connectionId: options.connectionId, }); }, - [navigate, token], + [dispatch, navigate, t, token], ); const afterMcpSave = async () => { diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index b63234b4..c8340a24 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1120,7 +1120,7 @@ "status": { "connect": "Verbinden", "connected": "Verbunden", - "connectedCount": "{{count}} verbunden", + "connectedCount": "{{formatted}} verbunden", "reconnect": "Neu verbinden", "needsAdminSetup": "Admin-Einrichtung nötig", "disabledByAdmin": "Vom Admin deaktiviert", @@ -1190,13 +1190,15 @@ "always": "Immer erlauben", "ask": "Freigabe nötig", "off": "Aus", - "label": "Berechtigung für {{action}}" + "label": "Berechtigung für {{action}}", + "saveFailed": "Die Berechtigung konnte nicht gespeichert werden. Versuche es erneut." }, "disconnect": { "title": "{{name}} trennen?", "body_zero": "Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest.", "body_one": "Deine Quelle behält ihren Inhalt, wird aber nicht mehr synchronisiert. Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest.", - "body_other": "Deine {{count}} Quellen behalten ihren Inhalt, werden aber nicht mehr synchronisiert. Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest." + "body_other": "Deine {{formatted}} Quellen behalten ihren Inhalt, werden aber nicht mehr synchronisiert. Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest.", + "failed": "Trennen fehlgeschlagen. Versuche es erneut." }, "fields": { "token": "API-Schlüssel", @@ -1226,11 +1228,11 @@ "doneTitle": "{{name}} ist verbunden", "doneSummary": "Verbunden als {{account}}. {{sources}} werden synchronisiert, {{tools}} bereit.", "doneSummaryNone": "Verbunden als {{account}}.", - "sourcesCount_one": "{{count}} Quelle", - "sourcesCount_other": "{{count}} Quellen", - "toolsCount_one": "{{count}} Werkzeug", - "toolsCount_other": "{{count}} Werkzeuge", - "toolsHeading": "Werkzeuge ({{count}})", + "sourcesCount_one": "{{formatted}} Quelle", + "sourcesCount_other": "{{formatted}} Quellen", + "toolsCount_one": "{{formatted}} Werkzeug", + "toolsCount_other": "{{formatted}} Werkzeuge", + "toolsHeading": "Werkzeuge ({{formatted}})", "tryInChat": "Im Chat ausprobieren", "done": "Fertig", "connectFailed": "Verbindung fehlgeschlagen. Prüfe die Angaben und versuche es erneut.", @@ -1244,11 +1246,11 @@ "title": "{{name}} entfernen?", "description": "Die gespeicherte Anmeldung wird gelöscht. Wähle, was mit den Inhalten dieser Verbindung passiert.", "sourcesLabel_one": "Ihre Quelle", - "sourcesLabel_other": "Ihre {{count}} Quellen", + "sourcesLabel_other": "Ihre {{formatted}} Quellen", "keepSources": "Inhalt behalten (Sync stoppen)", "deleteSources": "Löschen", "toolsLabel_one": "Ihr Werkzeug", - "toolsLabel_other": "Ihre {{count}} Werkzeuge", + "toolsLabel_other": "Ihre {{formatted}} Werkzeuge", "deleteTools": "Löschen", "keepTools": "Behalten, ausgeschaltet", "failed": "Die Verbindung konnte nicht entfernt werden. Versuche es erneut." @@ -1271,6 +1273,12 @@ "memberNote": "Jedes Mitglied verbindet sein eigenes {{name}}-Konto, bevor es dieses Werkzeug nutzen kann.", "forced": "Ein Admin hat das für alle Freigaben dieses Konnektors festgelegt.", "saveFailed": "Die Änderung konnte nicht gespeichert werden. Versuche es erneut." + }, + "createFailed": "Der Konnektor konnte nicht hinzugefügt werden. Versuche es erneut.", + "capabilityFilter": { + "sync": "Angezeigt werden Konnektoren, die Inhalte in Quellen synchronisieren.", + "tools": "Angezeigt werden Konnektoren, die Agenten Werkzeuge geben.", + "showAll": "Alle anzeigen" } } }, diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index d5bfc6e1..352df12b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1126,7 +1126,7 @@ "status": { "connect": "Connect", "connected": "Connected", - "connectedCount": "{{count}} connected", + "connectedCount": "{{formatted}} connected", "reconnect": "Reconnect", "needsAdminSetup": "Needs admin setup", "disabledByAdmin": "Disabled by admin", @@ -1196,13 +1196,15 @@ "always": "Always allow", "ask": "Needs approval", "off": "Off", - "label": "Permission for {{action}}" + "label": "Permission for {{action}}", + "saveFailed": "Could not save the permission. Try again." }, "disconnect": { "title": "Disconnect {{name}}?", "body_zero": "Tools from this connection stop working until you reconnect.", "body_one": "Your source keeps its content but stops syncing. Tools from this connection stop working until you reconnect.", - "body_other": "Your {{count}} sources keep their content but stop syncing. Tools from this connection stop working until you reconnect." + "body_other": "Your {{formatted}} sources keep their content but stop syncing. Tools from this connection stop working until you reconnect.", + "failed": "Could not disconnect. Try again." }, "fields": { "token": "API key", @@ -1232,11 +1234,11 @@ "doneTitle": "{{name}} is connected", "doneSummary": "Connected as {{account}}. {{sources}} syncing, {{tools}} ready.", "doneSummaryNone": "Connected as {{account}}.", - "sourcesCount_one": "{{count}} source", - "sourcesCount_other": "{{count}} sources", - "toolsCount_one": "{{count}} tool", - "toolsCount_other": "{{count}} tools", - "toolsHeading": "Tools ({{count}})", + "sourcesCount_one": "{{formatted}} source", + "sourcesCount_other": "{{formatted}} sources", + "toolsCount_one": "{{formatted}} tool", + "toolsCount_other": "{{formatted}} tools", + "toolsHeading": "Tools ({{formatted}})", "tryInChat": "Try it in chat", "done": "Done", "connectFailed": "Could not connect. Check the details and try again.", @@ -1250,11 +1252,11 @@ "title": "Remove {{name}}?", "description": "The stored sign-in is deleted. Choose what happens to what this connection feeds.", "sourcesLabel_one": "Its source", - "sourcesLabel_other": "Its {{count}} sources", + "sourcesLabel_other": "Its {{formatted}} sources", "keepSources": "Keep content (stop syncing)", "deleteSources": "Delete", "toolsLabel_one": "Its tool", - "toolsLabel_other": "Its {{count}} tools", + "toolsLabel_other": "Its {{formatted}} tools", "deleteTools": "Delete", "keepTools": "Keep, turned off", "failed": "Could not remove the connection. Try again." @@ -1277,6 +1279,12 @@ "memberNote": "Each member connects their own {{name}} account before they can use this tool.", "forced": "An admin chose this for every share of this connector.", "saveFailed": "Could not save the change. Try again." + }, + "createFailed": "Could not add the connector. Try again.", + "capabilityFilter": { + "sync": "Showing connectors that sync content into Sources.", + "tools": "Showing connectors that give agents tools.", + "showAll": "Show all" } } }, @@ -2616,9 +2624,6 @@ "body": "These were used this {{period}} and recorded at $0: {{models}}. Use a token limit for them, or declare their rates in the model catalog.", "model": "{{model}} ({{tokens}} tokens)" } - }, - "connectors": { - "defaultKey": "Set ENCRYPTION_SECRET_KEY before connecting services." } } } diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 26f54d29..59646209 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1120,7 +1120,7 @@ "status": { "connect": "Conectar", "connected": "Conectado", - "connectedCount": "{{count}} conectadas", + "connectedCount": "{{formatted}} conectadas", "reconnect": "Reconectar", "needsAdminSetup": "Requiere configuración del administrador", "disabledByAdmin": "Desactivado por el administrador", @@ -1190,13 +1190,15 @@ "always": "Permitir siempre", "ask": "Requiere aprobación", "off": "Desactivada", - "label": "Permiso para {{action}}" + "label": "Permiso para {{action}}", + "saveFailed": "No se pudo guardar el permiso. Inténtalo de nuevo." }, "disconnect": { "title": "¿Desconectar {{name}}?", "body_zero": "Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla.", "body_one": "Tu fuente conserva su contenido pero deja de sincronizarse. Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla.", - "body_other": "Tus {{count}} fuentes conservan su contenido pero dejan de sincronizarse. Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla." + "body_other": "Tus {{formatted}} fuentes conservan su contenido pero dejan de sincronizarse. Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla.", + "failed": "No se pudo desconectar. Inténtalo de nuevo." }, "fields": { "token": "Clave de API", @@ -1226,11 +1228,11 @@ "doneTitle": "{{name}} está conectado", "doneSummary": "Conectado como {{account}}. {{sources}} sincronizando, {{tools}} listas.", "doneSummaryNone": "Conectado como {{account}}.", - "sourcesCount_one": "{{count}} fuente", - "sourcesCount_other": "{{count}} fuentes", - "toolsCount_one": "{{count}} herramienta", - "toolsCount_other": "{{count}} herramientas", - "toolsHeading": "Herramientas ({{count}})", + "sourcesCount_one": "{{formatted}} fuente", + "sourcesCount_other": "{{formatted}} fuentes", + "toolsCount_one": "{{formatted}} herramienta", + "toolsCount_other": "{{formatted}} herramientas", + "toolsHeading": "Herramientas ({{formatted}})", "tryInChat": "Probar en el chat", "done": "Listo", "connectFailed": "No se pudo conectar. Revisa los datos e inténtalo de nuevo.", @@ -1244,11 +1246,11 @@ "title": "¿Eliminar {{name}}?", "description": "Se borra el inicio de sesión guardado. Elige qué pasa con lo que alimenta esta conexión.", "sourcesLabel_one": "Su fuente", - "sourcesLabel_other": "Sus {{count}} fuentes", + "sourcesLabel_other": "Sus {{formatted}} fuentes", "keepSources": "Conservar el contenido (dejar de sincronizar)", "deleteSources": "Eliminar", "toolsLabel_one": "Su herramienta", - "toolsLabel_other": "Sus {{count}} herramientas", + "toolsLabel_other": "Sus {{formatted}} herramientas", "deleteTools": "Eliminar", "keepTools": "Conservar, desactivadas", "failed": "No se pudo eliminar la conexión. Inténtalo de nuevo." @@ -1271,6 +1273,12 @@ "memberNote": "Cada miembro conecta su propia cuenta de {{name}} antes de poder usar esta herramienta.", "forced": "Un administrador eligió esto para todos los recursos compartidos de este conector.", "saveFailed": "No se pudo guardar el cambio. Inténtalo de nuevo." + }, + "createFailed": "No se pudo añadir el conector. Inténtalo de nuevo.", + "capabilityFilter": { + "sync": "Se muestran los conectores que sincronizan contenido en Fuentes.", + "tools": "Se muestran los conectores que dan herramientas a los agentes.", + "showAll": "Mostrar todos" } } }, diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 1af6c99a..3efbfed4 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1111,7 +1111,7 @@ "status": { "connect": "接続", "connected": "接続済み", - "connectedCount": "{{count}} 件接続済み", + "connectedCount": "{{formatted}} 件接続済み", "reconnect": "再接続", "needsAdminSetup": "管理者の設定が必要", "disabledByAdmin": "管理者により無効", @@ -1181,12 +1181,14 @@ "always": "常に許可", "ask": "承認が必要", "off": "オフ", - "label": "{{action}} の権限" + "label": "{{action}} の権限", + "saveFailed": "権限を保存できませんでした。もう一度お試しください。" }, "disconnect": { "title": "{{name}} を切断しますか?", "body_zero": "この接続のツールは、再接続するまで使えなくなります。", - "body_other": "{{count}} 件のソースはコンテンツを保持しますが、同期は停止します。この接続のツールは、再接続するまで使えなくなります。" + "body_other": "{{formatted}} 件のソースはコンテンツを保持しますが、同期は停止します。この接続のツールは、再接続するまで使えなくなります。", + "failed": "切断できませんでした。もう一度お試しください。" }, "fields": { "token": "API キー", @@ -1216,9 +1218,9 @@ "doneTitle": "{{name}} に接続しました", "doneSummary": "{{account}} として接続しました。{{sources}}を同期中、{{tools}}を利用できます。", "doneSummaryNone": "{{account}} として接続しました。", - "sourcesCount_other": "{{count}} 件のソース", - "toolsCount_other": "{{count}} 個のツール", - "toolsHeading": "ツール ({{count}})", + "sourcesCount_other": "{{formatted}} 件のソース", + "toolsCount_other": "{{formatted}} 個のツール", + "toolsHeading": "ツール ({{formatted}})", "tryInChat": "チャットで試す", "done": "完了", "connectFailed": "接続できませんでした。入力内容を確認して、もう一度お試しください。", @@ -1231,10 +1233,10 @@ "remove": { "title": "{{name}} を削除しますか?", "description": "保存されたサインイン情報は削除されます。この接続から提供されているものをどうするか選んでください。", - "sourcesLabel_other": "{{count}} 件のソース", + "sourcesLabel_other": "{{formatted}} 件のソース", "keepSources": "コンテンツを保持(同期を停止)", "deleteSources": "削除", - "toolsLabel_other": "{{count}} 個のツール", + "toolsLabel_other": "{{formatted}} 個のツール", "deleteTools": "削除", "keepTools": "保持してオフにする", "failed": "接続を削除できませんでした。もう一度お試しください。" @@ -1257,6 +1259,12 @@ "memberNote": "各メンバーは、このツールを使う前に自分の {{name}} アカウントを接続します。", "forced": "このコネクタのすべての共有に対して管理者が設定しています。", "saveFailed": "変更を保存できませんでした。もう一度お試しください。" + }, + "createFailed": "コネクタを追加できませんでした。もう一度お試しください。", + "capabilityFilter": { + "sync": "ソースにコンテンツを同期するコネクタを表示しています。", + "tools": "エージェントにツールを提供するコネクタを表示しています。", + "showAll": "すべて表示" } } }, diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 2dda6203..27f1da56 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1176,7 +1176,7 @@ "status": { "connect": "Подключить", "connected": "Подключено", - "connectedCount": "Подключено: {{count}}", + "connectedCount": "Подключено: {{formatted}}", "reconnect": "Переподключить", "needsAdminSetup": "Нужна настройка администратором", "disabledByAdmin": "Отключено администратором", @@ -1246,15 +1246,17 @@ "always": "Всегда разрешать", "ask": "Нужно одобрение", "off": "Выкл.", - "label": "Разрешение для {{action}}" + "label": "Разрешение для {{action}}", + "saveFailed": "Не удалось сохранить разрешение. Попробуйте ещё раз." }, "disconnect": { "title": "Отключить {{name}}?", "body_zero": "Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", - "body_one": "Ваш {{count}} источник сохранит контент, но перестанет синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", - "body_few": "Ваши {{count}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", - "body_many": "Ваши {{count}} источников сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", - "body_other": "Ваши {{count}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь." + "body_one": "Ваш {{formatted}} источник сохранит контент, но перестанет синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", + "body_few": "Ваши {{formatted}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", + "body_many": "Ваши {{formatted}} источников сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", + "body_other": "Ваши {{formatted}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", + "failed": "Не удалось отключить. Попробуйте ещё раз." }, "fields": { "token": "API-ключ", @@ -1284,15 +1286,15 @@ "doneTitle": "{{name}} подключён", "doneSummary": "Подключено как {{account}}. Синхронизируется: {{sources}}, готово: {{tools}}.", "doneSummaryNone": "Подключено как {{account}}.", - "sourcesCount_one": "{{count}} источник", - "sourcesCount_few": "{{count}} источника", - "sourcesCount_many": "{{count}} источников", - "sourcesCount_other": "{{count}} источника", - "toolsCount_one": "{{count}} инструмент", - "toolsCount_few": "{{count}} инструмента", - "toolsCount_many": "{{count}} инструментов", - "toolsCount_other": "{{count}} инструмента", - "toolsHeading": "Инструменты ({{count}})", + "sourcesCount_one": "{{formatted}} источник", + "sourcesCount_few": "{{formatted}} источника", + "sourcesCount_many": "{{formatted}} источников", + "sourcesCount_other": "{{formatted}} источника", + "toolsCount_one": "{{formatted}} инструмент", + "toolsCount_few": "{{formatted}} инструмента", + "toolsCount_many": "{{formatted}} инструментов", + "toolsCount_other": "{{formatted}} инструмента", + "toolsHeading": "Инструменты ({{formatted}})", "tryInChat": "Попробовать в чате", "done": "Готово", "connectFailed": "Не удалось подключиться. Проверьте данные и попробуйте ещё раз.", @@ -1306,15 +1308,15 @@ "title": "Удалить {{name}}?", "description": "Сохранённый вход будет удалён. Выберите, что станет с тем, что даёт это подключение.", "sourcesLabel_one": "Его источник", - "sourcesLabel_few": "Его {{count}} источника", - "sourcesLabel_many": "Его {{count}} источников", - "sourcesLabel_other": "Его {{count}} источника", + "sourcesLabel_few": "Его {{formatted}} источника", + "sourcesLabel_many": "Его {{formatted}} источников", + "sourcesLabel_other": "Его {{formatted}} источника", "keepSources": "Оставить контент (остановить синхронизацию)", "deleteSources": "Удалить", "toolsLabel_one": "Его инструмент", - "toolsLabel_few": "Его {{count}} инструмента", - "toolsLabel_many": "Его {{count}} инструментов", - "toolsLabel_other": "Его {{count}} инструмента", + "toolsLabel_few": "Его {{formatted}} инструмента", + "toolsLabel_many": "Его {{formatted}} инструментов", + "toolsLabel_other": "Его {{formatted}} инструмента", "deleteTools": "Удалить", "keepTools": "Оставить выключенными", "failed": "Не удалось удалить подключение. Попробуйте ещё раз." @@ -1337,6 +1339,12 @@ "memberNote": "Каждый участник подключает свой аккаунт {{name}}, прежде чем пользоваться этим инструментом.", "forced": "Администратор задал это для всех общих доступов к этому коннектору.", "saveFailed": "Не удалось сохранить изменение. Попробуйте ещё раз." + }, + "createFailed": "Не удалось добавить коннектор. Попробуйте ещё раз.", + "capabilityFilter": { + "sync": "Показаны коннекторы, которые синхронизируют содержимое в источники.", + "tools": "Показаны коннекторы, которые дают агентам инструменты.", + "showAll": "Показать все" } } }, diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 18fed160..a8f016dc 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1111,7 +1111,7 @@ "status": { "connect": "連線", "connected": "已連線", - "connectedCount": "已連線 {{count}} 個", + "connectedCount": "已連線 {{formatted}} 個", "reconnect": "重新連線", "needsAdminSetup": "需要管理員設定", "disabledByAdmin": "已被管理員停用", @@ -1181,12 +1181,14 @@ "always": "一律允許", "ask": "需要核准", "off": "關閉", - "label": "{{action}} 的權限" + "label": "{{action}} 的權限", + "saveFailed": "無法儲存權限。請再試一次。" }, "disconnect": { "title": "要中斷 {{name}} 的連線嗎?", "body_zero": "在重新連線之前,此連線的工具將無法使用。", - "body_other": "你的 {{count}} 個來源會保留內容,但會停止同步。在重新連線之前,此連線的工具將無法使用。" + "body_other": "你的 {{formatted}} 個來源會保留內容,但會停止同步。在重新連線之前,此連線的工具將無法使用。", + "failed": "無法中斷連線。請再試一次。" }, "fields": { "token": "API 金鑰", @@ -1216,9 +1218,9 @@ "doneTitle": "{{name}} 已連線", "doneSummary": "已以 {{account}} 連線。{{sources}}同步中,{{tools}}已就緒。", "doneSummaryNone": "已以 {{account}} 連線。", - "sourcesCount_other": "{{count}} 個來源", - "toolsCount_other": "{{count}} 個工具", - "toolsHeading": "工具({{count}})", + "sourcesCount_other": "{{formatted}} 個來源", + "toolsCount_other": "{{formatted}} 個工具", + "toolsHeading": "工具({{formatted}})", "tryInChat": "在聊天中試試", "done": "完成", "connectFailed": "無法連線。請檢查資料後再試一次。", @@ -1231,10 +1233,10 @@ "remove": { "title": "要移除 {{name}} 嗎?", "description": "儲存的登入資訊將被刪除。請選擇如何處理此連線提供的內容。", - "sourcesLabel_other": "它的 {{count}} 個來源", + "sourcesLabel_other": "它的 {{formatted}} 個來源", "keepSources": "保留內容(停止同步)", "deleteSources": "刪除", - "toolsLabel_other": "它的 {{count}} 個工具", + "toolsLabel_other": "它的 {{formatted}} 個工具", "deleteTools": "刪除", "keepTools": "保留並關閉", "failed": "無法移除此連線。請再試一次。" @@ -1257,6 +1259,12 @@ "memberNote": "每位成員須先連線自己的 {{name}} 帳號,才能使用此工具。", "forced": "管理員已為此連接器的所有共用設定此項。", "saveFailed": "無法儲存變更。請再試一次。" + }, + "createFailed": "無法新增連接器。請再試一次。", + "capabilityFilter": { + "sync": "正在顯示將內容同步到來源的連接器。", + "tools": "正在顯示為代理提供工具的連接器。", + "showAll": "顯示全部" } } }, diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 85caa7cf..4bbfa986 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1111,7 +1111,7 @@ "status": { "connect": "连接", "connected": "已连接", - "connectedCount": "已连接 {{count}} 个", + "connectedCount": "已连接 {{formatted}} 个", "reconnect": "重新连接", "needsAdminSetup": "需要管理员设置", "disabledByAdmin": "已被管理员停用", @@ -1181,12 +1181,14 @@ "always": "始终允许", "ask": "需要批准", "off": "关闭", - "label": "{{action}} 的权限" + "label": "{{action}} 的权限", + "saveFailed": "无法保存权限。请重试。" }, "disconnect": { "title": "断开 {{name}}?", "body_zero": "在重新连接之前,此连接的工具将无法使用。", - "body_other": "你的 {{count}} 个来源会保留内容,但将停止同步。在重新连接之前,此连接的工具将无法使用。" + "body_other": "你的 {{formatted}} 个来源会保留内容,但将停止同步。在重新连接之前,此连接的工具将无法使用。", + "failed": "无法断开连接。请重试。" }, "fields": { "token": "API 密钥", @@ -1216,9 +1218,9 @@ "doneTitle": "{{name}} 已连接", "doneSummary": "已作为 {{account}} 连接。{{sources}}正在同步,{{tools}}已就绪。", "doneSummaryNone": "已作为 {{account}} 连接。", - "sourcesCount_other": "{{count}} 个来源", - "toolsCount_other": "{{count}} 个工具", - "toolsHeading": "工具({{count}})", + "sourcesCount_other": "{{formatted}} 个来源", + "toolsCount_other": "{{formatted}} 个工具", + "toolsHeading": "工具({{formatted}})", "tryInChat": "在聊天中试试", "done": "完成", "connectFailed": "无法连接。请检查信息后重试。", @@ -1231,10 +1233,10 @@ "remove": { "title": "移除 {{name}}?", "description": "保存的登录信息将被删除。请选择如何处理此连接提供的内容。", - "sourcesLabel_other": "它的 {{count}} 个来源", + "sourcesLabel_other": "它的 {{formatted}} 个来源", "keepSources": "保留内容(停止同步)", "deleteSources": "删除", - "toolsLabel_other": "它的 {{count}} 个工具", + "toolsLabel_other": "它的 {{formatted}} 个工具", "deleteTools": "删除", "keepTools": "保留并关闭", "failed": "无法移除此连接。请重试。" @@ -1257,6 +1259,12 @@ "memberNote": "每位成员需先连接自己的 {{name}} 账号才能使用此工具。", "forced": "管理员已为此连接器的所有共享设定了此项。", "saveFailed": "无法保存更改。请重试。" + }, + "createFailed": "无法添加连接器。请重试。", + "capabilityFilter": { + "sync": "正在显示将内容同步到来源的连接器。", + "tools": "正在显示为智能体提供工具的连接器。", + "showAll": "显示全部" } } }, diff --git a/frontend/src/modals/AddToolModal.tsx b/frontend/src/modals/AddToolModal.tsx index 0e79c97c..9e54e6a7 100644 --- a/frontend/src/modals/AddToolModal.tsx +++ b/frontend/src/modals/AddToolModal.tsx @@ -1,15 +1,15 @@ -import { ArrowRight, Plus } from 'lucide-react'; +import { ArrowRight } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import userService from '../api/services/userService'; import SkeletonLoader from '../components/SkeletonLoader'; import ToolIcon from '../components/ToolIcon'; import { Button } from '../components/ui/button'; import { Card, CardDescription, CardTitle } from '../components/ui/card'; -import { Modal } from '../components/ui/modal'; +import { Modal, ModalActions } from '../components/ui/modal'; import { SectionHeader } from '../components/ui/section-header'; import { useLoaderState } from '../hooks'; import { @@ -40,6 +40,7 @@ export default function AddToolModal({ onDevicePaired?: (deviceId: string) => void; }) { const { t } = useTranslation(); + const navigate = useNavigate(); const token = useSelector(selectToken); const dispatch = useDispatch(); const catalog = useSelector(selectConnectorCatalog); @@ -141,6 +142,28 @@ export default function AddToolModal({ onOpenChange={(o) => !o && setModalState('INACTIVE')} title={t('settings.tools.selectToolSetup')} size="xl" + footer={ + + setModalState('INACTIVE')} + > + {t('settings.tools.browseConnectors')} + + + + } + cancelLabel={t('cancel')} + onCancel={() => setModalState('INACTIVE')} + submitLabel={t('settings.connectors.addCustom')} + onSubmit={() => { + setModalState('INACTIVE'); + navigate('/settings/connectors?filter=custom'); + }} + /> + } >
{loading ? ( @@ -205,26 +228,6 @@ export default function AddToolModal({ ); }) )} -
- - -
{ expect(container.textContent).toContain('settings.connectors.empty'); }); + it('says when the composer narrowed the list, and clears it', async () => { + await render('/settings/connectors?capability=sync'); + expect(container.textContent).toContain( + 'settings.connectors.capabilityFilter.sync', + ); + expect(card('telegram')).toBeNull(); + const showAll = Array.from(container.querySelectorAll('button')).find( + (button) => + button.textContent === 'settings.connectors.capabilityFilter.showAll', + )!; + await act(async () => showAll.click()); + expect(container.textContent).not.toContain( + 'settings.connectors.capabilityFilter.sync', + ); + expect(card('telegram')).not.toBeNull(); + }); + + it('only offers categories that have connectors', async () => { + await render(); + const pills = Array.from( + container.querySelectorAll('[data-slot="toggle-group-item"]'), + ).map((item) => item.textContent); + expect(pills).toContain('settings.connectors.categories.all'); + expect(pills).toContain('settings.connectors.categories.files'); + expect(pills).not.toContain('settings.connectors.categories.database'); + }); + it('shows a retry when the catalog fails to load', async () => { service.getCatalog.mockResolvedValue({ success: false }); await render(); diff --git a/frontend/src/settings/Connectors.tsx b/frontend/src/settings/Connectors.tsx index 843bdfbf..0916793e 100644 --- a/frontend/src/settings/Connectors.tsx +++ b/frontend/src/settings/Connectors.tsx @@ -15,6 +15,13 @@ import { DropdownMenuTrigger, } from '../components/ui/dropdown-menu'; import { EmptyState } from '../components/ui/empty-state'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group'; import ConnectionDrawer from '../connectors/ConnectionDrawer'; import ConnectorCard from '../connectors/ConnectorCard'; @@ -86,6 +93,21 @@ export default function Connectors() { // "Connect more" in the composer opens the connectors that can do what the // picker is for: sync content, or give tools. const capability = searchParams.get('capability'); + const clearCapability = () => { + searchParams.delete('capability'); + setSearchParams(searchParams, { replace: true }); + }; + + // Only categories that have something in them (connectors that need admin + // setup are not listed, which can empty one). + const filters = FILTERS.filter( + (key) => + key === 'all' || + key === filter || + (key === 'connected' + ? catalog.some(isConnected) + : catalog.some((connector) => connector.category === key)), + ); const visible = useMemo(() => { const query = search.trim().toLowerCase(); @@ -163,19 +185,57 @@ export default function Connectors() { } > - value && setFilter(value as Filter)} - aria-label={t('settings.connectors.categoriesLabel')} - className="mb-6" - > - {FILTERS.map((key) => ( - - {t(`settings.connectors.categories.${key}`)} - - ))} - +
+ {/* Nine pills wrap to four lines on a phone: a Select there. */} + value && setFilter(value as Filter)} + aria-label={t('settings.connectors.categoriesLabel')} + className="hidden sm:flex" + > + {filters.map((key) => ( + + {t(`settings.connectors.categories.${key}`)} + + ))} + +
+ +
+ {(capability === 'sync' || capability === 'tools') && ( +

+ {t(`settings.connectors.capabilityFilter.${capability}`)}{' '} + +

+ )} +
{!loaded && !failed ? ( @@ -191,6 +251,7 @@ export default function Connectors() { - ); - })()}
+ {(() => { + const connection = tool.connection_id + ? connections.find( + (c) => c.id === tool.connection_id, + ) + : undefined; + if (!connection) return null; + // The same meta line as a Sources tile; mr-12 keeps + // it clear of the status switch. + return ( + + + + + {t('settings.tools.viaConnection', { + name: connection.name, + interpolation: { escapeValue: false }, + })} + + + + ); + })()}
-