From aad43c2fd0a8c7165137a28513b2d8b2fa0cdcd3 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:30:56 +0100 Subject: [PATCH] Add the Connectors page Settings > Workspace gets a Connectors page between Sources and Tools: a searchable catalog filtered by category, one card per service with its capabilities (Sync, Read, Write) and state (Connect, Connected, Reconnect, Needs admin setup, Disabled by admin, Custom). A card opens a drawer listing the accounts, the sources each syncs and the tools each provides, with Reconnect and Disconnect. Tools and Sources show which connection an item comes from and when its sync is paused. Brand icons now draw in currentColor so they follow the theme, and all new copy is translated in every locale. --- frontend/src/api/endpoints.ts | 5 + .../src/api/services/connectorsService.ts | 29 ++ frontend/src/assets/confluence.svg | 2 +- frontend/src/assets/connectors/asana.svg | 1 + frontend/src/assets/connectors/atlassian.svg | 1 + frontend/src/assets/connectors/linear.svg | 1 + frontend/src/assets/connectors/notion.svg | 1 + frontend/src/assets/connectors/sentry.svg | 1 + frontend/src/assets/connectors/stripe.svg | 1 + frontend/src/assets/drive.svg | 2 +- frontend/src/assets/reddit.svg | 4 +- frontend/src/assets/s3.svg | 10 +- frontend/src/assets/sharepoint.svg | 2 +- frontend/src/connectors/ConnectionDrawer.tsx | 438 ++++++++++++++++++ frontend/src/connectors/ConnectorCard.tsx | 138 ++++++ frontend/src/connectors/ConnectorIcon.tsx | 70 +++ frontend/src/connectors/connectorsSlice.ts | 80 ++++ frontend/src/connectors/i18n.ts | 29 ++ frontend/src/connectors/types.ts | 114 +++++ .../src/connectors/useConnectorLauncher.tsx | 140 ++++++ frontend/src/locale/connectors.test.ts | 72 +++ frontend/src/locale/de.json | 128 ++++- frontend/src/locale/en.json | 128 ++++- frontend/src/locale/es.json | 128 ++++- frontend/src/locale/jp.json | 127 ++++- frontend/src/locale/ru.json | 130 +++++- frontend/src/locale/zh-TW.json | 127 ++++- frontend/src/locale/zh.json | 127 ++++- frontend/src/modals/ConfirmationModal.tsx | 4 + frontend/src/modals/MCPServerModal.tsx | 10 +- frontend/src/models/misc.ts | 2 + frontend/src/navigation/sections.ts | 7 + frontend/src/settings/Connectors.test.tsx | 217 +++++++++ frontend/src/settings/Connectors.tsx | 220 +++++++++ frontend/src/settings/Sources.tsx | 49 +- frontend/src/settings/Tools.tsx | 55 ++- frontend/src/settings/index.tsx | 2 + frontend/src/settings/types/index.ts | 2 + frontend/src/store.ts | 2 + 39 files changed, 2502 insertions(+), 104 deletions(-) create mode 100644 frontend/src/api/services/connectorsService.ts create mode 100644 frontend/src/assets/connectors/asana.svg create mode 100644 frontend/src/assets/connectors/atlassian.svg create mode 100644 frontend/src/assets/connectors/linear.svg create mode 100644 frontend/src/assets/connectors/notion.svg create mode 100644 frontend/src/assets/connectors/sentry.svg create mode 100644 frontend/src/assets/connectors/stripe.svg create mode 100644 frontend/src/connectors/ConnectionDrawer.tsx create mode 100644 frontend/src/connectors/ConnectorCard.tsx create mode 100644 frontend/src/connectors/ConnectorIcon.tsx create mode 100644 frontend/src/connectors/connectorsSlice.ts create mode 100644 frontend/src/connectors/i18n.ts create mode 100644 frontend/src/connectors/types.ts create mode 100644 frontend/src/connectors/useConnectorLauncher.tsx create mode 100644 frontend/src/locale/connectors.test.ts create mode 100644 frontend/src/settings/Connectors.test.tsx create mode 100644 frontend/src/settings/Connectors.tsx diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 7989be60..d621dfea 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -87,6 +87,11 @@ const endpoints = { CONNECTOR_FILES: '/api/connectors/files', CONNECTOR_VALIDATE_SESSION: '/api/connectors/validate-session', CONNECTOR_DISCONNECT: '/api/connectors/disconnect', + CONNECTORS_CATALOG: '/api/connectors/catalog', + CONNECTIONS: '/api/connections', + CONNECTION: (id: string) => `/api/connections/${encodeURIComponent(id)}`, + CONNECTION_DISCONNECT: (id: string) => + `/api/connections/${encodeURIComponent(id)}/disconnect`, GET_CHUNKS: ( docId: string, page: number, diff --git a/frontend/src/api/services/connectorsService.ts b/frontend/src/api/services/connectorsService.ts new file mode 100644 index 00000000..19640996 --- /dev/null +++ b/frontend/src/api/services/connectorsService.ts @@ -0,0 +1,29 @@ +import apiClient, { throttledApiClient } from '../client'; +import endpoints from '../endpoints'; + +// apiClient resolves to the raw fetch Response; parse it here so the slice and +// components get plain data. A non-2xx body still parses (``success: false``). +const json = async (response: Response) => { + try { + return await response.json(); + } catch { + return { success: false }; + } +}; + +const connectorsService = { + getCatalog: async (token: string | null) => + json( + await throttledApiClient.get(endpoints.USER.CONNECTORS_CATALOG, token), + ), + listConnections: async (token: string | null) => + json(await throttledApiClient.get(endpoints.USER.CONNECTIONS, token)), + getConnection: async (id: string, token: string | null) => + json(await apiClient.get(endpoints.USER.CONNECTION(id), token)), + disconnect: async (id: string, token: string | null) => + json( + await apiClient.post(endpoints.USER.CONNECTION_DISCONNECT(id), {}, token), + ), +}; + +export default connectorsService; diff --git a/frontend/src/assets/confluence.svg b/frontend/src/assets/confluence.svg index 183807ad..34631723 100644 --- a/frontend/src/assets/confluence.svg +++ b/frontend/src/assets/confluence.svg @@ -1,4 +1,4 @@ - + diff --git a/frontend/src/assets/connectors/asana.svg b/frontend/src/assets/connectors/asana.svg new file mode 100644 index 00000000..3b2ba44c --- /dev/null +++ b/frontend/src/assets/connectors/asana.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/connectors/atlassian.svg b/frontend/src/assets/connectors/atlassian.svg new file mode 100644 index 00000000..76d35504 --- /dev/null +++ b/frontend/src/assets/connectors/atlassian.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/connectors/linear.svg b/frontend/src/assets/connectors/linear.svg new file mode 100644 index 00000000..583413fe --- /dev/null +++ b/frontend/src/assets/connectors/linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/connectors/notion.svg b/frontend/src/assets/connectors/notion.svg new file mode 100644 index 00000000..5a8188cb --- /dev/null +++ b/frontend/src/assets/connectors/notion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/connectors/sentry.svg b/frontend/src/assets/connectors/sentry.svg new file mode 100644 index 00000000..4f08902e --- /dev/null +++ b/frontend/src/assets/connectors/sentry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/connectors/stripe.svg b/frontend/src/assets/connectors/stripe.svg new file mode 100644 index 00000000..3ffcab80 --- /dev/null +++ b/frontend/src/assets/connectors/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/drive.svg b/frontend/src/assets/drive.svg index 8ec4dc9b..ea0c1607 100644 --- a/frontend/src/assets/drive.svg +++ b/frontend/src/assets/drive.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/src/assets/reddit.svg b/frontend/src/assets/reddit.svg index 705ef638..61d78727 100644 --- a/frontend/src/assets/reddit.svg +++ b/frontend/src/assets/reddit.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/src/assets/s3.svg b/frontend/src/assets/s3.svg index 3fdc41ff..c40cb06f 100644 --- a/frontend/src/assets/s3.svg +++ b/frontend/src/assets/s3.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/frontend/src/assets/sharepoint.svg b/frontend/src/assets/sharepoint.svg index 9a332f8e..8aa88c99 100644 --- a/frontend/src/assets/sharepoint.svg +++ b/frontend/src/assets/sharepoint.svg @@ -1,6 +1,6 @@ - + diff --git a/frontend/src/connectors/ConnectionDrawer.tsx b/frontend/src/connectors/ConnectionDrawer.tsx new file mode 100644 index 00000000..e37453a6 --- /dev/null +++ b/frontend/src/connectors/ConnectionDrawer.tsx @@ -0,0 +1,438 @@ +import { + CircleAlert, + ExternalLink, + Plus, + RefreshCw, + Unplug, +} from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; + +import connectorsService from '../api/services/connectorsService'; +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 { ActionMenu } from '../components/ui/dropdown-menu'; +import { EmptyState } from '../components/ui/empty-state'; +import { ListRow, ListRows } from '../components/ui/list-row'; +import { LoadingState } from '../components/ui/loading-state'; +import { SectionHeader } from '../components/ui/section-header'; +import { Sheet, SheetContent, SheetTitle } from '../components/ui/sheet'; +import ConfirmationModal from '../modals/ConfirmationModal'; +import { selectToken } from '../preferences/preferenceSlice'; +import type { AppDispatch } from '../store'; +import { formatDateTime } from '../utils/dateTimeUtils'; +import { CapabilityBadges } from './ConnectorCard'; +import ConnectorIcon from './ConnectorIcon'; +import { selectConnections, loadConnectors } from './connectorsSlice'; +import { connectorDescription, connectorName } from './i18n'; +import type { + ConnectionDetail, + ConnectionStatus, + ConnectionTool, + ConnectorDefinition, +} from './types'; + +const STATUS_VARIANT: Record< + ConnectionStatus, + 'success' | 'warning' | 'neutral' | 'destructive' +> = { + connected: 'success', + reconnect_needed: 'warning', + disconnected: 'neutral', + error: 'destructive', + pending: 'neutral', +}; + +const PERMISSION_VARIANT = { + always: 'success', + ask: 'warning', + off: 'neutral', +} as const; + +function ToolActions({ tool }: { tool: ConnectionTool }) { + const { t } = useTranslation(); + const groups = (['read', 'write'] as const) + .map((access) => ({ + access, + actions: tool.actions.filter((action) => action.access === access), + })) + .filter((group) => group.actions.length > 0); + return ( + +
+ + {tool.display_name} + + + {tool.status + ? t('settings.connectors.detail.toolOn') + : t('settings.connectors.detail.toolOff')} + +
+ {groups.map((group) => ( +
+ + +
+ ))} + + ); +} + +function AccountSection({ + detail, + onReconnect, + onDisconnect, +}: { + detail: ConnectionDetail; + onReconnect: () => void; + onDisconnect: (detail: ConnectionDetail) => void; +}) { + const { t } = useTranslation(); + return ( +
+ + + {detail.last_error} + ) : undefined + } + trailing={ +
+ + {t(`settings.connectors.connectionStatus.${detail.status}`)} + + {detail.status !== 'connected' && ( + + )} + {detail.status !== 'disconnected' && ( + onDisconnect(detail), + variant: 'destructive', + }, + ]} + /> + )} +
+ } + /> +
+
+ {detail.sources.length > 0 && ( +
+ + + + {detail.sources.map((source) => ( + + {t('settings.connectors.detail.paused')} + + ) : undefined + } + /> + ))} + + +
+ )} + {detail.tools.length > 0 && ( +
+ + {detail.tools.map((tool) => ( + + ))} +
+ )} +
+ ); +} + +/** + * Everything about one connector: its accounts, the sources each syncs and + * the tools each provides. Opens from a Connectors page card. + */ +export default function ConnectionDrawer({ + connector, + onClose, + onConnect, +}: { + connector: ConnectorDefinition | null; + onClose: () => void; + onConnect: (connector: ConnectorDefinition) => void; +}) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const token = useSelector(selectToken); + const connections = useSelector(selectConnections); + const [details, setDetails] = useState([]); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + const [toDisconnect, setToDisconnect] = useState( + null, + ); + + const accountIds = connections + .filter((connection) => connection.connector_key === connector?.key) + .map((connection) => connection.id) + .join(','); + + useEffect(() => { + if (!connector) return; + const ids = accountIds ? accountIds.split(',') : []; + let cancelled = false; + setLoading(true); + setFailed(false); + Promise.all(ids.map((id) => connectorsService.getConnection(id, token))) + .then((responses) => { + if (cancelled) return; + if (responses.some((response) => !response?.success)) { + setFailed(true); + return; + } + setDetails(responses.map((response) => response.connection)); + }) + .catch(() => !cancelled && setFailed(true)) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [connector, accountIds, token, reloadKey]); + + const refresh = useCallback(() => { + dispatch(loadConnectors({ token })); + setReloadKey((key) => key + 1); + }, [dispatch, token]); + + const confirmDisconnect = () => { + if (!toDisconnect) return; + connectorsService.disconnect(toDisconnect.id, token).finally(() => { + setToDisconnect(null); + refresh(); + }); + }; + + if (!connector) return null; + const name = connectorName(t, connector); + + return ( + <> + !open && onClose()}> + +
+
+ + + +
+ {name} +

+ {t(`settings.connectors.publisher.${connector.publisher}`)} +

+
+
+

+ {connectorDescription(t, connector)} +

+ + + {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 && ( + + )} +
+
+
+ )} + +
+ 0 ? ( + + ) : undefined + } + /> + {loading ? ( + + ) : failed ? ( + setReloadKey((key) => key + 1)} + > + {t('retry')} + + } + /> + ) : details.length === 0 ? ( + onConnect(connector)} + > + {t('settings.connectors.status.connect')} + + ) : undefined + } + /> + ) : ( +
+ {details.map((detail) => ( + onConnect(connector)} + onDisconnect={setToDisconnect} + /> + ))} +
+ )} +
+
+
+
+ state === 'INACTIVE' && setToDisconnect(null)} + handleSubmit={confirmDisconnect} + submitLabel={t('settings.connectors.detail.disconnect')} + variant="destructive" + /> + + ); +} diff --git a/frontend/src/connectors/ConnectorCard.tsx b/frontend/src/connectors/ConnectorCard.tsx new file mode 100644 index 00000000..40bf8703 --- /dev/null +++ b/frontend/src/connectors/ConnectorCard.tsx @@ -0,0 +1,138 @@ +import { Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Badge } from '../components/ui/badge'; +import { + Card, + CardDescription, + CardFooter, + CardTitle, +} from '../components/ui/card'; +import ConnectorIcon from './ConnectorIcon'; +import { connectorDescription, connectorName } from './i18n'; +import type { Capability, ConnectorDefinition } from './types'; + +// Sync is blue, Read green, Write red: what the connection does to your data. +const CAPABILITY_VARIANT: Record< + Capability, + 'info' | 'success' | 'destructive' +> = { + sync: 'info', + read: 'success', + write: 'destructive', +}; + +export function CapabilityBadges({ + capabilities, +}: { + capabilities: Capability[]; +}) { + const { t } = useTranslation(); + return ( +
+ {capabilities.map((capability) => ( + + {t(`settings.connectors.capability.${capability}`)} + + ))} +
+ ); +} + +/** The state a catalog card ends on: a badge, or the Connect call to action. */ +export function ConnectorStateBadge({ + connector, +}: { + connector: ConnectorDefinition; +}) { + const { t } = useTranslation(); + switch (connector.state) { + case 'connected': + return ( + + {connector.connected_count > 1 + ? t('settings.connectors.status.connectedCount', { + count: connector.connected_count, + }) + : t('settings.connectors.status.connected')} + + ); + case 'reconnect': + return ( + + {t('settings.connectors.status.reconnect')} + + ); + case 'needs_setup': + return ( + + {t('settings.connectors.status.needsAdminSetup')} + + ); + case 'disabled': + return ( + + {t('settings.connectors.status.disabledByAdmin')} + + ); + default: + return ( + + + ); + } +} + +/** + * A catalog tile. The whole card is the one action: it connects an + * available service and opens the connection details for everything else. + */ +export default function ConnectorCard({ + connector, + onOpen, +}: { + connector: ConnectorDefinition; + onOpen: (connector: ConnectorDefinition) => void; +}) { + const { t } = useTranslation(); + const name = connectorName(t, connector); + return ( + + + + ); +} diff --git a/frontend/src/connectors/ConnectorIcon.tsx b/frontend/src/connectors/ConnectorIcon.tsx new file mode 100644 index 00000000..da2020a9 --- /dev/null +++ b/frontend/src/connectors/ConnectorIcon.tsx @@ -0,0 +1,70 @@ +import { Plug } from 'lucide-react'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; +import ConfluenceIcon from '../assets/confluence.svg?react'; +import DriveIcon from '../assets/drive.svg?react'; +import RedditIcon from '../assets/reddit.svg?react'; +import S3Icon from '../assets/s3.svg?react'; +import SharePointIcon from '../assets/sharepoint.svg?react'; + +type SvgComponent = React.FC>; + +// Preset logos live in assets/connectors; built-in tool services reuse the +// tool icons. Every asset draws in currentColor unless it is a multi-colour +// brand mark (Brave), so the icon follows the theme through `text-*`. +const presetModules = import.meta.glob('../assets/connectors/*.svg', { + query: '?react', + import: 'default', + eager: true, +}) as Record; +const toolModules = import.meta.glob('../assets/toolIcons/tool_*.svg', { + query: '?react', + import: 'default', + eager: true, +}) as Record; + +const ICONS: Record = { + drive: DriveIcon, + sharepoint: SharePointIcon, + confluence: ConfluenceIcon, + s3: S3Icon, + reddit: RedditIcon, +}; +for (const [path, Component] of Object.entries(presetModules)) { + const match = path.match(/connectors\/(.+)\.svg$/); + if (match) ICONS[match[1]] = Component; +} +for (const [path, Component] of Object.entries(toolModules)) { + const match = path.match(/(tool_.+)\.svg$/); + if (match) ICONS[match[1]] = Component; +} + +type ConnectorIconProps = { + /** Catalog `icon` key, e.g. `drive`, `tool_telegram` or `notion`. */ + icon: string; + className?: string; + /** Accessible name. Omit when the connector's name is next to the icon. */ + title?: string; +}; + +/** + * A connector's logo. Unknown keys (a custom MCP server) fall back to a plug, + * the same glyph the Connectors page uses in the navigation. + */ +export default function ConnectorIcon({ + icon, + className, + title, +}: ConnectorIconProps) { + const Icon = ICONS[icon]; + const a11y = title + ? { role: 'img' as const, 'aria-label': title } + : { 'aria-hidden': true as const }; + if (!Icon) { + return ( + + ); + } + return ; +} diff --git a/frontend/src/connectors/connectorsSlice.ts b/frontend/src/connectors/connectorsSlice.ts new file mode 100644 index 00000000..cc26674b --- /dev/null +++ b/frontend/src/connectors/connectorsSlice.ts @@ -0,0 +1,80 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import connectorsService from '../api/services/connectorsService'; +import type { Connection, ConnectorDefinition } from './types'; + +export type ConnectorsState = { + catalog: ConnectorDefinition[]; + connections: Connection[]; + loading: boolean; + loaded: boolean; + failed: boolean; +}; + +const initialState: ConnectorsState = { + catalog: [], + connections: [], + loading: false, + loaded: false, + failed: false, +}; + +/** + * Load the catalog and the caller's connections together. Every screen that + * shows a connector's state (the Connectors page, Add Source, Add Tool, the + * composer pickers) reads this one copy, so a connect anywhere updates all. + */ +export const loadConnectors = createAsyncThunk< + { catalog: ConnectorDefinition[]; connections: Connection[] }, + { token: string | null } +>('connectors/load', async ({ token }) => { + const [catalog, connections] = await Promise.all([ + connectorsService.getCatalog(token), + connectorsService.listConnections(token), + ]); + if (!catalog?.success || !connections?.success) { + throw new Error('Failed to load connectors'); + } + return { + catalog: catalog.connectors ?? [], + connections: connections.connections ?? [], + }; +}); + +const connectorsSlice = createSlice({ + name: 'connectors', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(loadConnectors.pending, (state) => { + state.loading = true; + state.failed = false; + }) + .addCase(loadConnectors.fulfilled, (state, action) => { + state.loading = false; + state.loaded = true; + state.catalog = action.payload.catalog; + state.connections = action.payload.connections; + }) + .addCase(loadConnectors.rejected, (state) => { + state.loading = false; + state.failed = true; + }); + }, +}); + +type RootLike = { connectors: ConnectorsState }; + +export const selectConnectorCatalog = (state: RootLike) => + state.connectors.catalog; +export const selectConnections = (state: RootLike) => + state.connectors.connections; +export const selectConnectorsLoading = (state: RootLike) => + state.connectors.loading; +export const selectConnectorsLoaded = (state: RootLike) => + state.connectors.loaded; +export const selectConnectorsFailed = (state: RootLike) => + state.connectors.failed; + +export default connectorsSlice.reducer; diff --git a/frontend/src/connectors/i18n.ts b/frontend/src/connectors/i18n.ts new file mode 100644 index 00000000..565f5a67 --- /dev/null +++ b/frontend/src/connectors/i18n.ts @@ -0,0 +1,29 @@ +import type { TFunction } from 'i18next'; + +import type { ConnectorDefinition } from './types'; + +/** + * Locale key segment for a catalog key. Preset keys look like `mcp:notion`, + * and i18next reads `:` as a namespace separator, so it becomes `_`. + */ +export const connectorLocaleKey = (key: string) => key.replace(/[:.]/g, '_'); + +/** The card's one-line description, translated when the locale has it. */ +export const connectorDescription = ( + t: TFunction, + connector: Pick, +) => + t(`settings.connectors.descriptions.${connectorLocaleKey(connector.key)}`, { + defaultValue: connector.description, + }); + +/** The connector's name; only the two custom kinds have translated names. */ +export const connectorName = ( + t: TFunction, + connector: Pick, +) => + connector.publisher === 'custom' + ? t(`settings.connectors.custom.${connectorLocaleKey(connector.key)}`, { + defaultValue: connector.name, + }) + : connector.name; diff --git a/frontend/src/connectors/types.ts b/frontend/src/connectors/types.ts new file mode 100644 index 00000000..557e4491 --- /dev/null +++ b/frontend/src/connectors/types.ts @@ -0,0 +1,114 @@ +export type ConnectorCategory = + | 'files' + | 'knowledge' + | 'dev' + | 'messaging' + | 'database' + | 'search' + | 'custom'; + +export type Capability = 'sync' | 'read' | 'write'; + +export type ConnectorAuthKind = + 'oauth' | 'mcp_oauth' | 'api_key' | 'none' | 'mcp'; + +export type ConnectionStatus = + 'connected' | 'reconnect_needed' | 'disconnected' | 'error' | 'pending'; + +/** What a catalog card shows, worked out on the server. */ +export type ConnectorCardState = + | 'available' + | 'connected' + | 'reconnect' + | 'needs_setup' + | 'disabled' + | 'custom'; + +export type CredentialMode = 'owner' | 'member'; + +export type CredentialField = { + key: string; + label: string; + secret: boolean; + required: boolean; +}; + +export type ConnectorDefinition = { + key: string; + name: string; + description: string; + icon: string; + category: ConnectorCategory; + auth_kind: ConnectorAuthKind; + capabilities: Capability[]; + credential_fields: CredentialField[]; + setup_fields: CredentialField[]; + sync_ingestor: string | null; + default_sync_frequency: string; + tool_templates: string[]; + setup: { tools: 'auto' | 'ask' | 'off'; sync: 'auto' | 'ask' | 'off' }; + mcp_url: string | null; + publisher: 'built_in' | 'preset' | 'custom'; + docs_url: string | null; + oauth_scopes: string[]; + available: boolean; + disabled: boolean; + needs_setup: boolean; + missing_settings: string[]; + connected_count: number; + connection_count: number; + status: ConnectionStatus | null; + state: ConnectorCardState; + credential_policy: 'choose' | CredentialMode; +}; + +export type Connection = { + id: string; + connector_key: string; + name: string; + display_name: string | null; + icon: string; + account_label: string; + auth_kind: ConnectorAuthKind | null; + status: ConnectionStatus; + server_url: string | null; + last_error: string | null; + created_at: string | null; + updated_at: string | null; + last_used_at: string | null; + source_count: number; + tool_count: number; +}; + +export type ActionAccess = 'read' | 'write'; +export type ActionPermission = 'always' | 'ask' | 'off'; + +export type ConnectionToolAction = { + name: string; + description: string; + access: ActionAccess; + permission: ActionPermission; +}; + +export type ConnectionTool = { + id: string; + name: string; + display_name: string; + status: boolean; + credential_mode: CredentialMode; + actions: ConnectionToolAction[]; +}; + +export type ConnectionSource = { + id: string; + name: string; + type: string; + last_sync: string | null; + sync_frequency: string; + sync_state: 'active' | 'paused_reconnect'; +}; + +export type ConnectionDetail = Connection & { + sources: ConnectionSource[]; + tools: ConnectionTool[]; +}; diff --git a/frontend/src/connectors/useConnectorLauncher.tsx b/frontend/src/connectors/useConnectorLauncher.tsx new file mode 100644 index 00000000..df8d81d1 --- /dev/null +++ b/frontend/src/connectors/useConnectorLauncher.tsx @@ -0,0 +1,140 @@ +import { useCallback, useState, type ReactNode } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import userService from '../api/services/userService'; +import ConfigToolModal from '../modals/ConfigToolModal'; +import MCPServerModal from '../modals/MCPServerModal'; +import type { AvailableToolType } from '../modals/types'; +import type { ActiveState } from '../models/misc'; +import { selectToken } from '../preferences/preferenceSlice'; +import type { AppDispatch } from '../store'; +import Upload from '../upload/Upload'; +import type { IngestorType } from '../upload/types/ingestor'; +import { loadConnectors } from './connectorsSlice'; +import type { ConnectorDefinition } from './types'; + +type Launch = + | { kind: 'source'; ingestor: IngestorType } + | { kind: 'tool'; tool: AvailableToolType } + | { kind: 'mcp'; server?: Record } + | null; + +/** + * One way to start connecting any catalog entry, used by every entry point + * (the Connectors page, Add Source, Add Tool). Returns `launch` and the + * modals it drives; render `modals` once where the hook is used. + */ +export default function useConnectorLauncher({ + onConnected, +}: { onConnected?: () => void } = {}) { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const token = useSelector(selectToken); + const [active, setActive] = useState(null); + + const finish = useCallback(() => { + setActive(null); + dispatch(loadConnectors({ token })); + onConnected?.(); + }, [dispatch, token, onConnected]); + + const launch = useCallback( + async (connector: ConnectorDefinition) => { + if (connector.sync_ingestor) { + setActive({ + kind: 'source', + ingestor: connector.sync_ingestor as IngestorType, + }); + return; + } + if ( + connector.auth_kind === 'mcp' || + connector.auth_kind === 'mcp_oauth' + ) { + setActive({ + kind: 'mcp', + server: connector.mcp_url + ? { + displayName: connector.name, + server_url: connector.mcp_url, + auth_type: + connector.auth_kind === 'mcp_oauth' ? 'oauth' : 'none', + preset: true, + } + : undefined, + }); + return; + } + const templateName = connector.tool_templates[0]; + if (!templateName) return; + const response = await userService.getAvailableTools(token); + const data = await response.json(); + const tool = (data.data as AvailableToolType[] | undefined)?.find( + (candidate) => candidate.name === templateName, + ); + if (!tool) return; + if (Object.keys(tool.configRequirements ?? {}).length === 0) { + // The OpenAPI connector: create the empty API tool and open it so + // the user can import a spec (today's API Tool flow). + 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) { + navigate('/settings/tools', { state: { openToolId: body.id } }); + } + return; + } + setActive({ kind: 'tool', tool }); + }, + [navigate, token], + ); + + const close = (state: ActiveState) => { + if (state === 'INACTIVE') setActive(null); + }; + + const modals: ReactNode = ( + <> + {active?.kind === 'source' && ( + setActive(null)} + initialIngestor={active.ingestor} + onSuccessfulUpload={finish} + selectUploadedDoc={false} + /> + )} + {active?.kind === 'tool' && ( + + )} + {active?.kind === 'mcp' && ( + + )} + + ); + + return { launch, modals }; +} diff --git a/frontend/src/locale/connectors.test.ts b/frontend/src/locale/connectors.test.ts new file mode 100644 index 00000000..7dbafc8c --- /dev/null +++ b/frontend/src/locale/connectors.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import de from './de.json'; +import en from './en.json'; +import es from './es.json'; +import jp from './jp.json'; +import ru from './ru.json'; +import zhTW from './zh-TW.json'; +import zh from './zh.json'; + +type Tree = { [key: string]: string | Tree }; + +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; + +const flatten = (tree: Tree, prefix = ''): string[] => + Object.entries(tree).flatMap(([key, value]) => + typeof value === 'string' + ? [prefix + key.replace(PLURAL_SUFFIX, '')] + : flatten(value, `${prefix}${key}.`), + ); + +const values = (tree: Tree): string[] => + Object.values(tree).flatMap((value) => + typeof value === 'string' ? [value] : values(value), + ); + +const block = (locale: object, path: string): Tree => + path + .split('.') + .reduce((node, key) => (node[key] as Tree) ?? {}, locale as Tree); + +const keysOf = (locale: object, path: string): string[] => + Array.from(new Set(flatten(block(locale, path)))).sort(); + +const LOCALES = { es, de, jp, ru, zh, zhTW }; + +// Every block the Connectors work adds strings to. +const BLOCKS = ['settings.connectors']; + +describe('connectors locale blocks', () => { + it.each(Object.entries(LOCALES))( + '%s has the same connector keys as en', + (_name, locale) => { + for (const path of BLOCKS) { + expect(keysOf(locale, path)).toEqual(keysOf(en, path)); + } + }, + ); + + it.each(Object.entries(LOCALES))( + '%s is translated, not an English copy', + (_name, locale) => { + const source = block(en, 'settings.connectors'); + const target = block(locale, 'settings.connectors'); + expect(target.subtitle).not.toBe(source.subtitle); + expect((target.status as Tree).connect).not.toBe( + (source.status as Tree).connect, + ); + }, + ); + + it.each(Object.entries({ en, ...LOCALES }))( + '%s has no em dash in connector strings', + (_name, locale) => { + for (const path of BLOCKS) { + for (const value of values(block(locale, path))) { + expect(value).not.toContain('—'); + } + } + }, + ); +}); diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 7c35fe9b..ea2373ff 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -97,7 +97,7 @@ }, "sources": { "title": "Hier kannst du alle verfügbaren Quelldateien verwalten, die dir zur Verfügung stehen und die du hochgeladen hast.", - "subtitle": "Dokumente und Wissensquellen hochladen und verwalten, die deine Antworten antreiben", + "subtitle": "Inhalte, die DocsGPT durchsuchen und zitieren kann: Uploads, Websites, Wikis und aus deinen Verbindungen synchronisierte Inhalte.", "label": "Quellen", "name": "Quellenname", "date": "Vektor-Datum", @@ -354,7 +354,8 @@ "forbidden": "Du hast keine Berechtigung, die Konfiguration dieser Quelle zu bearbeiten.", "saveFailed": "Konfiguration konnte nicht gespeichert werden. Bitte versuche es erneut." } - } + }, + "paused": "Sync pausiert. Verbinde {{name}} neu, um fortzufahren." }, "analytics": { "label": "Analytik", @@ -651,11 +652,11 @@ }, "tools": { "label": "Werkzeuge", - "subtitle": "Werkzeuge und Integrationen durchsuchen, verbinden und konfigurieren, die deine Agenten antreiben", + "subtitle": "Aktionen, die deine Agenten ausführen können. Integrierte Werkzeuge findest du hier. Werkzeuge eines verbundenen Dienstes sind unter diesem Dienst gruppiert.", "searchPlaceholder": "Werkzeuge suchen...", "addTool": "Werkzeug hinzufügen", "noToolsFound": "Keine Werkzeuge gefunden", - "selectToolSetup": "Wähle ein Werkzeug zur Einrichtung", + "selectToolSetup": "Werkzeug hinzufügen", "settingsIconAlt": "Einstellungssymbol", "configureToolAria": "{{toolName}} konfigurieren", "toggleToolAria": "{{toolName}} umschalten", @@ -667,8 +668,8 @@ "builtIn": "Integriert", "authStatus": { "connected": "Verbunden", - "needsAuth": "Authentifizierung nötig", - "configured": "Konfiguriert" + "needsAuth": "Neu verbinden", + "configured": "Verbunden" }, "deleteWarning": "Bist du sicher, dass du das Werkzeug \"{{toolName}}\" löschen möchtest?", "unsavedChanges": "Du hast ungespeicherte Änderungen, die verloren gehen, wenn du ohne Speichern verlässt.", @@ -773,7 +774,11 @@ "maxTimeout": "Das maximale Timeout beträgt 300 Sekunden" }, "headerValuePlaceholder": "z. B. application/json", - "toolIconTitle": "{{name}}-Symbol" + "toolIconTitle": "{{name}}-Symbol", + "groupBuiltIn": "Integriert", + "groupService": "Aus einem Dienst", + "browseConnectors": "Suchst du Notion, Linear oder einen anderen Dienst? Konnektoren durchsuchen", + "viaConnection": "über {{name}}" }, "devices": { "label": "Geräte", @@ -1002,13 +1007,107 @@ "subtitle": "Das neue Geheimnis für \"{{name}}\" ist bereit. Das alte funktioniert nicht mehr." } } + }, + "connectors": { + "label": "Konnektoren", + "subtitle": "Verbinde DocsGPT mit den Diensten, die dein Team nutzt. Eine Verbindung kann Inhalte in Quellen synchronisieren und Agenten Werkzeuge geben.", + "search": "Konnektoren suchen...", + "addCustom": "Eigenen Konnektor hinzufügen", + "custom": { + "custom_mcp": "MCP-Server", + "custom_openapi": "OpenAPI / REST" + }, + "categoriesLabel": "Konnektoren nach Kategorie filtern", + "categories": { + "all": "Alle", + "connected": "Verbunden", + "files": "Dateien und Dokumente", + "knowledge": "Wissensdatenbanken", + "dev": "Entwicklerwerkzeuge", + "messaging": "Nachrichten", + "database": "Datenbanken", + "search": "Suche", + "custom": "Eigene" + }, + "capability": { + "sync": "Sync", + "read": "Lesen", + "write": "Schreiben" + }, + "status": { + "connect": "Verbinden", + "connected": "Verbunden", + "connectedCount": "{{count}} verbunden", + "reconnect": "Neu verbinden", + "needsAdminSetup": "Admin-Einrichtung nötig", + "disabledByAdmin": "Vom Admin deaktiviert", + "custom": "Eigener" + }, + "connectionStatus": { + "connected": "Verbunden", + "reconnect_needed": "Neu verbinden nötig", + "disconnected": "Getrennt", + "error": "Fehler", + "pending": "Wartet auf Anmeldung" + }, + "publisher": { + "built_in": "Integriert", + "preset": "Vorlage", + "custom": "Eigener" + }, + "descriptions": { + "google_drive": "Docs, Tabellen und PDFs in Quellen synchronisieren.", + "share_point": "Dateien aus SharePoint-Sites und OneDrive in Quellen synchronisieren.", + "confluence": "Confluence-Bereiche und -Seiten in Quellen synchronisieren.", + "s3": "Dokumente aus einem S3-Bucket in Quellen synchronisieren.", + "reddit": "Reddit-Beiträge zu deinen Suchen in Quellen synchronisieren.", + "brave": "Web und Bilder mit der Brave Search API durchsuchen.", + "telegram": "Nachrichten und Bilder an einen Telegram-Chat senden.", + "ntfy": "Push-Benachrichtigungen über einen ntfy-Server senden.", + "postgres": "Schema lesen und SQL auf einer Postgres-Datenbank ausführen.", + "custom_mcp": "Einen beliebigen Remote-Server für das Model Context Protocol verbinden.", + "custom_openapi": "Eine OpenAPI-Spezifikation importieren und ihre Endpunkte als Werkzeuge aufrufen." + }, + "empty": "Noch nichts verbunden. Verbinde einen Dienst, um seine Inhalte zu synchronisieren oder Agenten neue Werkzeuge zu geben.", + "noMatches": "Keine Konnektoren passen zu deiner Suche.", + "loadFailed": "Konnektoren konnten nicht geladen werden.", + "askAdmin": "Bitte einen Admin, dies einzurichten.", + "setupSettings": "Setze diese Servereinstellungen und starte DocsGPT neu:", + "setupGuide": "Einrichtungsanleitung", + "unverified": "Dieser Server wurde nicht von DocsGPT geprüft. Verbinde nur Server, denen du vertraust.", + "detail": { + "accounts": "Konten", + "connectedAs": "Verbunden als {{account}}", + "connectAnother": "Weiteres Konto verbinden", + "sources": "Quellen aus dieser Verbindung", + "tools": "Werkzeuge aus dieser Verbindung", + "noAccounts": "Noch kein Konto verbunden.", + "failed": "Diese Verbindung konnte nicht geladen werden.", + "lastSync": "Synchronisiert {{date}} · {{frequency}}", + "paused": "Sync pausiert", + "toolOn": "An", + "toolOff": "Aus", + "accountMenu": "Kontoaktionen", + "disconnect": "Trennen" + }, + "permission": { + "always": "Immer erlauben", + "ask": "Freigabe nötig", + "off": "Aus" + }, + "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." + } } }, "modals": { "uploadDoc": { - "label": "Neues Dokument hochladen", + "label": "Quelle hinzufügen", "select": "Wähle, wie du dein Dokument zu DocsGPT hochladen möchtest", - "selectSource": "Wähle die Art, wie du deine Quelle hinzufügen möchtest", + "selectSource": "Quelle hinzufügen", "selectedFiles": "Ausgewählte Dateien", "noFilesSelected": "Keine Dateien ausgewählt", "file": "Vom Gerät hochladen", @@ -1021,7 +1120,7 @@ "info": "Bitte lade .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip hoch (max. 25 MB)", "uploadedFiles": "Hochgeladene Dateien", "cancel": "Abbrechen", - "train": "Trainieren", + "train": "Quelle hinzufügen", "create": "Erstellen", "link": "Link", "urlLink": "URL-Link", @@ -1154,7 +1253,10 @@ "region": "AWS-Region", "endpointUrl": "Benutzerdefinierte Endpunkt-URL (optional)", "initialContent": "Anfangsinhalt (optional)" - } + }, + "groupUploadWeb": "Upload und Web", + "groupConnection": "Aus einer Verbindung", + "tileConnectedAs": "Verbunden als {{account}}" }, "createAPIKey": { "label": "Neuen API-Schlüssel erstellen", @@ -1190,8 +1292,8 @@ "configTool": { "title": "Werkzeug-Konfiguration", "type": "Typ", - "apiKeyLabel": "API-Schlüssel / OAuth", - "apiKeyPlaceholder": "API-Schlüssel / OAuth eingeben", + "apiKeyLabel": "API-Schlüssel", + "apiKeyPlaceholder": "API-Schlüssel eingeben", "addButton": "Werkzeug hinzufügen", "closeButton": "Schließen", "customNamePlaceholder": "Benutzerdefinierten Namen eingeben (optional)", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 986577a1..af1443a8 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -101,7 +101,7 @@ }, "sources": { "title": "Here you can manage all of the source file that are available to you and those you have uploaded.", - "subtitle": "Upload and manage the documents and knowledge sources used to power your responses", + "subtitle": "Content DocsGPT can search and cite: uploads, websites, wikis and content synced from your connections.", "label": "Sources", "name": "Source Name", "date": "Vector Date", @@ -359,7 +359,8 @@ "forbidden": "You don't have permission to edit this source's config.", "saveFailed": "Failed to save config. Please try again." } - } + }, + "paused": "Sync paused. Reconnect {{name}} to resume." }, "analytics": { "label": "Analytics", @@ -657,11 +658,11 @@ }, "tools": { "label": "Tools", - "subtitle": "Browse, connect, and configure the tools and integrations that power your agents", + "subtitle": "Actions your agents can take. Built-in tools live here. Tools from a connected service are grouped under that service.", "searchPlaceholder": "Search tools...", "addTool": "Add Tool", "noToolsFound": "No tools found", - "selectToolSetup": "Select a tool to set up", + "selectToolSetup": "Add a tool", "settingsIconAlt": "Settings icon", "configureToolAria": "Configure {{toolName}}", "toggleToolAria": "Toggle {{toolName}}", @@ -673,8 +674,8 @@ "builtIn": "Built-in", "authStatus": { "connected": "Connected", - "needsAuth": "Needs Auth", - "configured": "Configured" + "needsAuth": "Reconnect", + "configured": "Connected" }, "deleteWarning": "Are you sure you want to delete the tool \"{{toolName}}\"?", "unsavedChanges": "You have unsaved changes that will be lost if you leave without saving.", @@ -779,7 +780,11 @@ "maxTimeout": "Maximum timeout is 300 seconds" }, "headerValuePlaceholder": "e.g., application/json", - "toolIconTitle": "{{name}} icon" + "toolIconTitle": "{{name}} icon", + "groupBuiltIn": "Built in", + "groupService": "From a service", + "browseConnectors": "Looking for Notion, Linear or another service? Browse connectors", + "viaConnection": "via {{name}}" }, "devices": { "label": "Devices", @@ -1008,13 +1013,107 @@ "subtitle": "The new secret for \"{{name}}\" is ready. The old one no longer works." } } + }, + "connectors": { + "label": "Connectors", + "subtitle": "Connect DocsGPT to the services your team uses. A connection can sync content into Sources and give agents tools.", + "search": "Search connectors...", + "addCustom": "Add custom connector", + "custom": { + "custom_mcp": "MCP server", + "custom_openapi": "OpenAPI / REST" + }, + "categoriesLabel": "Filter connectors by category", + "categories": { + "all": "All", + "connected": "Connected", + "files": "Files and docs", + "knowledge": "Knowledge bases", + "dev": "Developer tools", + "messaging": "Messaging", + "database": "Databases", + "search": "Search", + "custom": "Custom" + }, + "capability": { + "sync": "Sync", + "read": "Read", + "write": "Write" + }, + "status": { + "connect": "Connect", + "connected": "Connected", + "connectedCount": "{{count}} connected", + "reconnect": "Reconnect", + "needsAdminSetup": "Needs admin setup", + "disabledByAdmin": "Disabled by admin", + "custom": "Custom" + }, + "connectionStatus": { + "connected": "Connected", + "reconnect_needed": "Reconnect needed", + "disconnected": "Disconnected", + "error": "Error", + "pending": "Waiting for sign-in" + }, + "publisher": { + "built_in": "Built in", + "preset": "Preset", + "custom": "Custom" + }, + "descriptions": { + "google_drive": "Sync Docs, Sheets and PDFs into Sources.", + "share_point": "Sync files from SharePoint sites and OneDrive into Sources.", + "confluence": "Sync Confluence spaces and pages into Sources.", + "s3": "Sync documents from an S3 bucket into Sources.", + "reddit": "Sync Reddit posts that match your searches into Sources.", + "brave": "Search the web and images with the Brave Search API.", + "telegram": "Send messages and images to a Telegram chat.", + "ntfy": "Send push notifications through an ntfy server.", + "postgres": "Read the schema and run SQL against a Postgres database.", + "custom_mcp": "Connect any remote Model Context Protocol server.", + "custom_openapi": "Import an OpenAPI spec and call its endpoints as tools." + }, + "empty": "Nothing connected yet. Connect a service to sync its content or give agents new tools.", + "noMatches": "No connectors match your search.", + "loadFailed": "Could not load connectors.", + "askAdmin": "Ask an admin to set this up.", + "setupSettings": "Set these server settings, then restart DocsGPT:", + "setupGuide": "Setup guide", + "unverified": "This server is not reviewed by DocsGPT. Only connect servers you trust.", + "detail": { + "accounts": "Accounts", + "connectedAs": "Connected as {{account}}", + "connectAnother": "Connect another account", + "sources": "Sources from this connection", + "tools": "Tools from this connection", + "noAccounts": "No account connected yet.", + "failed": "Could not load this connection.", + "lastSync": "Synced {{date}} · {{frequency}}", + "paused": "Sync paused", + "toolOn": "On", + "toolOff": "Off", + "accountMenu": "Account actions", + "disconnect": "Disconnect" + }, + "permission": { + "always": "Always allow", + "ask": "Needs approval", + "off": "Off" + }, + "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." + } } }, "modals": { "uploadDoc": { - "label": "Upload new document", + "label": "Add a source", "select": "Choose how to upload your document to DocsGPT", - "selectSource": "Select the way to add your source", + "selectSource": "Add a source", "selectedFiles": "Selected Files", "noFilesSelected": "No files selected", "file": "Upload from device", @@ -1027,7 +1126,7 @@ "info": "Please upload .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip limited to 25mb", "uploadedFiles": "Uploaded Files", "cancel": "Cancel", - "train": "Train", + "train": "Add source", "create": "Create", "link": "Link", "urlLink": "URL Link", @@ -1160,7 +1259,10 @@ "region": "AWS Region", "endpointUrl": "Custom Endpoint URL (optional)", "initialContent": "Initial content (optional)" - } + }, + "groupUploadWeb": "Upload and web", + "groupConnection": "From a connection", + "tileConnectedAs": "Connected as {{account}}" }, "createAPIKey": { "label": "Create New API Key", @@ -1196,8 +1298,8 @@ "configTool": { "title": "Tool Config", "type": "Type", - "apiKeyLabel": "API Key / OAuth", - "apiKeyPlaceholder": "Enter API Key / OAuth", + "apiKeyLabel": "API key", + "apiKeyPlaceholder": "Enter API key", "addButton": "Add Tool", "closeButton": "Close", "customNamePlaceholder": "Enter custom name (optional)", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 681e2e0d..4f009258 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -97,7 +97,7 @@ }, "sources": { "title": "Aquí puedes gestionar todos los archivos fuente que están disponibles para ti y los que has subido.", - "subtitle": "Sube y gestiona los documentos y fuentes de conocimiento que impulsan tus respuestas", + "subtitle": "Contenido que DocsGPT puede buscar y citar: archivos subidos, sitios web, wikis y contenido sincronizado desde tus conexiones.", "label": "Fuentes", "name": "Nombre de la Fuente", "date": "Fecha de Vector", @@ -354,7 +354,8 @@ "forbidden": "No tienes permiso para editar la configuración de esta fuente.", "saveFailed": "No se pudo guardar la configuración. Inténtalo de nuevo." } - } + }, + "paused": "Sincronización en pausa. Reconecta {{name}} para reanudarla." }, "analytics": { "label": "Analítica", @@ -651,11 +652,11 @@ }, "tools": { "label": "Herramientas", - "subtitle": "Explora, conecta y configura las herramientas e integraciones que impulsan a tus agentes", + "subtitle": "Acciones que pueden realizar tus agentes. Aquí están las herramientas integradas. Las herramientas de un servicio conectado se agrupan bajo ese servicio.", "searchPlaceholder": "Buscar...", "addTool": "Agregar Herramienta", "noToolsFound": "No se encontraron herramientas", - "selectToolSetup": "Seleccione una herramienta para configurar", + "selectToolSetup": "Añadir una herramienta", "settingsIconAlt": "Icono de configuración", "configureToolAria": "Configurar {{toolName}}", "toggleToolAria": "Alternar {{toolName}}", @@ -667,8 +668,8 @@ "builtIn": "Integrada", "authStatus": { "connected": "Conectado", - "needsAuth": "Requiere autenticación", - "configured": "Configurado" + "needsAuth": "Reconectar", + "configured": "Conectado" }, "deleteWarning": "¿Estás seguro de que deseas eliminar la herramienta \"{{toolName}}\"?", "unsavedChanges": "Tienes cambios sin guardar que se perderán si sales sin guardar.", @@ -773,7 +774,11 @@ "maxTimeout": "El tiempo de espera máximo es de 300 segundos" }, "headerValuePlaceholder": "p. ej., application/json", - "toolIconTitle": "Icono de {{name}}" + "toolIconTitle": "Icono de {{name}}", + "groupBuiltIn": "Integradas", + "groupService": "De un servicio", + "browseConnectors": "¿Buscas Notion, Linear u otro servicio? Explora los conectores", + "viaConnection": "mediante {{name}}" }, "devices": { "label": "Dispositivos", @@ -1002,13 +1007,107 @@ "subtitle": "El nuevo secreto de \"{{name}}\" está listo. El anterior ya no funciona." } } + }, + "connectors": { + "label": "Conectores", + "subtitle": "Conecta DocsGPT a los servicios que usa tu equipo. Una conexión puede sincronizar contenido en Fuentes y dar herramientas a los agentes.", + "search": "Buscar conectores...", + "addCustom": "Añadir conector personalizado", + "custom": { + "custom_mcp": "Servidor MCP", + "custom_openapi": "OpenAPI / REST" + }, + "categoriesLabel": "Filtrar conectores por categoría", + "categories": { + "all": "Todos", + "connected": "Conectados", + "files": "Archivos y documentos", + "knowledge": "Bases de conocimiento", + "dev": "Herramientas de desarrollo", + "messaging": "Mensajería", + "database": "Bases de datos", + "search": "Búsqueda", + "custom": "Personalizados" + }, + "capability": { + "sync": "Sincronizar", + "read": "Leer", + "write": "Escribir" + }, + "status": { + "connect": "Conectar", + "connected": "Conectado", + "connectedCount": "{{count}} conectadas", + "reconnect": "Reconectar", + "needsAdminSetup": "Requiere configuración del administrador", + "disabledByAdmin": "Desactivado por el administrador", + "custom": "Personalizado" + }, + "connectionStatus": { + "connected": "Conectado", + "reconnect_needed": "Hay que reconectar", + "disconnected": "Desconectado", + "error": "Error", + "pending": "Esperando el inicio de sesión" + }, + "publisher": { + "built_in": "Integrado", + "preset": "Predefinido", + "custom": "Personalizado" + }, + "descriptions": { + "google_drive": "Sincroniza Docs, Hojas de cálculo y PDF en Fuentes.", + "share_point": "Sincroniza archivos de sitios de SharePoint y OneDrive en Fuentes.", + "confluence": "Sincroniza espacios y páginas de Confluence en Fuentes.", + "s3": "Sincroniza documentos de un bucket de S3 en Fuentes.", + "reddit": "Sincroniza en Fuentes las publicaciones de Reddit que coinciden con tus búsquedas.", + "brave": "Busca en la web e imágenes con la API de Brave Search.", + "telegram": "Envía mensajes e imágenes a un chat de Telegram.", + "ntfy": "Envía notificaciones push mediante un servidor ntfy.", + "postgres": "Lee el esquema y ejecuta SQL en una base de datos Postgres.", + "custom_mcp": "Conecta cualquier servidor remoto de Model Context Protocol.", + "custom_openapi": "Importa una especificación OpenAPI y usa sus endpoints como herramientas." + }, + "empty": "Aún no hay nada conectado. Conecta un servicio para sincronizar su contenido o dar nuevas herramientas a los agentes.", + "noMatches": "Ningún conector coincide con tu búsqueda.", + "loadFailed": "No se pudieron cargar los conectores.", + "askAdmin": "Pide a un administrador que lo configure.", + "setupSettings": "Define estos ajustes del servidor y reinicia DocsGPT:", + "setupGuide": "Guía de configuración", + "unverified": "DocsGPT no ha revisado este servidor. Conecta solo servidores de confianza.", + "detail": { + "accounts": "Cuentas", + "connectedAs": "Conectado como {{account}}", + "connectAnother": "Conectar otra cuenta", + "sources": "Fuentes de esta conexión", + "tools": "Herramientas de esta conexión", + "noAccounts": "Aún no hay ninguna cuenta conectada.", + "failed": "No se pudo cargar esta conexión.", + "lastSync": "Sincronizado {{date}} · {{frequency}}", + "paused": "Sincronización en pausa", + "toolOn": "Activada", + "toolOff": "Desactivada", + "accountMenu": "Acciones de la cuenta", + "disconnect": "Desconectar" + }, + "permission": { + "always": "Permitir siempre", + "ask": "Requiere aprobación", + "off": "Desactivada" + }, + "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." + } } }, "modals": { "uploadDoc": { - "label": "Subir nuevo documento", + "label": "Añadir una fuente", "select": "Elige cómo cargar tu documento en DocsGPT", - "selectSource": "Selecciona la forma de agregar tu fuente", + "selectSource": "Añadir una fuente", "selectedFiles": "Archivos Seleccionados", "noFilesSelected": "No hay archivos seleccionados", "file": "Subir desde el dispositivo", @@ -1021,7 +1120,7 @@ "info": "Por favor, sube archivos .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip limitados a 25MB", "uploadedFiles": "Archivos Subidos", "cancel": "Cancelar", - "train": "Entrenar", + "train": "Añadir fuente", "create": "Crear", "link": "Enlace", "urlLink": "Enlace URL", @@ -1154,7 +1253,10 @@ "region": "Región de AWS", "endpointUrl": "URL de endpoint personalizada (opcional)", "initialContent": "Contenido inicial (opcional)" - } + }, + "groupUploadWeb": "Subida y web", + "groupConnection": "Desde una conexión", + "tileConnectedAs": "Conectado como {{account}}" }, "createAPIKey": { "label": "Crear Nueva Clave de API", @@ -1190,8 +1292,8 @@ "configTool": { "title": "Configuración de la Herramienta", "type": "Tipo", - "apiKeyLabel": "Clave API / OAuth", - "apiKeyPlaceholder": "Ingrese la Clave API / OAuth", + "apiKeyLabel": "Clave de API", + "apiKeyPlaceholder": "Introduce la clave de API", "addButton": "Agregar Herramienta", "closeButton": "Cerrar", "customNamePlaceholder": "Enter custom name (optional)", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 9e0fa1dd..2595f2f3 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -97,7 +97,7 @@ }, "sources": { "title": "ここでは、利用可能なすべてのソースファイルとアップロードしたファイルを管理できます。", - "subtitle": "レスポンスを機能させるドキュメントとナレッジソースをアップロードして管理", + "subtitle": "DocsGPT が検索・引用できるコンテンツ:アップロード、ウェブサイト、Wiki、接続から同期したコンテンツ。", "label": "ソース", "name": "ソース名", "date": "ベクトル日付", @@ -353,7 +353,8 @@ "forbidden": "このソースの設定を編集する権限がありません。", "saveFailed": "設定の保存に失敗しました。もう一度お試しください。" } - } + }, + "paused": "同期を一時停止中です。再開するには {{name}} を再接続してください。" }, "analytics": { "label": "分析", @@ -650,11 +651,11 @@ }, "tools": { "label": "ツール", - "subtitle": "エージェントを強化するツールやインテグレーションを検索、接続、設定", + "subtitle": "エージェントが実行できる操作です。組み込みツールはここにあります。接続したサービスのツールは、そのサービスの下にまとめて表示されます。", "searchPlaceholder": "検索...", "addTool": "ツールを追加", "noToolsFound": "ツールが見つかりません", - "selectToolSetup": "設定するツールを選択してください", + "selectToolSetup": "ツールを追加", "settingsIconAlt": "設定アイコン", "configureToolAria": "{{toolName}}を設定", "toggleToolAria": "{{toolName}}を切り替え", @@ -666,8 +667,8 @@ "builtIn": "ビルトイン", "authStatus": { "connected": "接続済み", - "needsAuth": "要認証", - "configured": "設定済み" + "needsAuth": "再接続", + "configured": "接続済み" }, "deleteWarning": "ツール \"{{toolName}}\" を削除してもよろしいですか?", "unsavedChanges": "保存されていない変更があります。保存せずに離れると失われます。", @@ -772,7 +773,11 @@ "maxTimeout": "タイムアウトの上限は300秒です" }, "headerValuePlaceholder": "例: application/json", - "toolIconTitle": "{{name}}のアイコン" + "toolIconTitle": "{{name}}のアイコン", + "groupBuiltIn": "組み込み", + "groupService": "サービスから", + "browseConnectors": "Notion、Linear などのサービスをお探しですか?コネクタを見る", + "viaConnection": "{{name}} 経由" }, "devices": { "label": "デバイス", @@ -1001,13 +1006,106 @@ "subtitle": "「{{name}}」の新しいシークレットの準備ができました。以前のものは使用できません。" } } + }, + "connectors": { + "label": "コネクタ", + "subtitle": "DocsGPT をチームで使っているサービスに接続します。接続ごとに、コンテンツをソースへ同期したり、エージェントにツールを提供したりできます。", + "search": "コネクタを検索...", + "addCustom": "カスタムコネクタを追加", + "custom": { + "custom_mcp": "MCP サーバー", + "custom_openapi": "OpenAPI / REST" + }, + "categoriesLabel": "カテゴリでコネクタを絞り込む", + "categories": { + "all": "すべて", + "connected": "接続済み", + "files": "ファイルとドキュメント", + "knowledge": "ナレッジベース", + "dev": "開発者ツール", + "messaging": "メッセージ", + "database": "データベース", + "search": "検索", + "custom": "カスタム" + }, + "capability": { + "sync": "同期", + "read": "読み取り", + "write": "書き込み" + }, + "status": { + "connect": "接続", + "connected": "接続済み", + "connectedCount": "{{count}} 件接続済み", + "reconnect": "再接続", + "needsAdminSetup": "管理者の設定が必要", + "disabledByAdmin": "管理者により無効", + "custom": "カスタム" + }, + "connectionStatus": { + "connected": "接続済み", + "reconnect_needed": "再接続が必要", + "disconnected": "切断済み", + "error": "エラー", + "pending": "サインイン待ち" + }, + "publisher": { + "built_in": "組み込み", + "preset": "プリセット", + "custom": "カスタム" + }, + "descriptions": { + "google_drive": "ドキュメント、スプレッドシート、PDF をソースに同期します。", + "share_point": "SharePoint サイトと OneDrive のファイルをソースに同期します。", + "confluence": "Confluence のスペースとページをソースに同期します。", + "s3": "S3 バケットのドキュメントをソースに同期します。", + "reddit": "検索に一致する Reddit の投稿をソースに同期します。", + "brave": "Brave Search API でウェブと画像を検索します。", + "telegram": "Telegram のチャットにメッセージや画像を送信します。", + "ntfy": "ntfy サーバー経由でプッシュ通知を送信します。", + "postgres": "Postgres データベースのスキーマを読み取り、SQL を実行します。", + "custom_mcp": "任意のリモート Model Context Protocol サーバーに接続します。", + "custom_openapi": "OpenAPI 仕様をインポートし、そのエンドポイントをツールとして呼び出します。" + }, + "empty": "まだ何も接続されていません。サービスを接続すると、コンテンツを同期したり、エージェントに新しいツールを提供したりできます。", + "noMatches": "検索に一致するコネクタはありません。", + "loadFailed": "コネクタを読み込めませんでした。", + "askAdmin": "管理者に設定を依頼してください。", + "setupSettings": "次のサーバー設定を行い、DocsGPT を再起動してください:", + "setupGuide": "設定ガイド", + "unverified": "このサーバーは DocsGPT による確認を受けていません。信頼できるサーバーにのみ接続してください。", + "detail": { + "accounts": "アカウント", + "connectedAs": "{{account}} として接続中", + "connectAnother": "別のアカウントを接続", + "sources": "この接続のソース", + "tools": "この接続のツール", + "noAccounts": "まだアカウントが接続されていません。", + "failed": "この接続を読み込めませんでした。", + "lastSync": "{{date}} に同期 · {{frequency}}", + "paused": "同期を一時停止中", + "toolOn": "オン", + "toolOff": "オフ", + "accountMenu": "アカウントの操作", + "disconnect": "切断" + }, + "permission": { + "always": "常に許可", + "ask": "承認が必要", + "off": "オフ" + }, + "disconnect": { + "title": "{{name}} を切断しますか?", + "body_zero": "この接続のツールは、再接続するまで使えなくなります。", + "body_other": "{{count}} 件のソースはコンテンツを保持しますが、同期は停止します。この接続のツールは、再接続するまで使えなくなります。" + } } }, "modals": { "uploadDoc": { - "label": "新しい文書をアップロードする", + "label": "ソースを追加", "select": "ドキュメントを DocsGPT にアップロードする方法を選択します", - "selectSource": "ソースを追加する方法を選択してください", + "selectSource": "ソースを追加", "selectedFiles": "選択されたファイル", "noFilesSelected": "ファイルが選択されていません", "file": "デバイスからアップロード", @@ -1020,7 +1118,7 @@ "info": "25MBまでの.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zipファイルをアップロードしてください", "uploadedFiles": "アップロードされたファイル", "cancel": "キャンセル", - "train": "トレーニング", + "train": "ソースを追加", "create": "作成", "link": "リンク", "urlLink": "URLリンク", @@ -1153,7 +1251,10 @@ "region": "AWS リージョン", "endpointUrl": "カスタムエンドポイント URL(任意)", "initialContent": "初期コンテンツ(任意)" - } + }, + "groupUploadWeb": "アップロードとウェブ", + "groupConnection": "接続から", + "tileConnectedAs": "{{account}} として接続中" }, "createAPIKey": { "label": "新しいAPIキーを作成", @@ -1189,8 +1290,8 @@ "configTool": { "title": "ツール設定", "type": "タイプ", - "apiKeyLabel": "APIキー / OAuth", - "apiKeyPlaceholder": "APIキー / OAuthを入力してください", + "apiKeyLabel": "API キー", + "apiKeyPlaceholder": "API キーを入力", "addButton": "ツールを追加", "closeButton": "閉じる", "customNamePlaceholder": "Enter custom name (optional)", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index c00face0..b3cb6d3d 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -97,7 +97,7 @@ }, "sources": { "title": "Здесь вы можете управлять всеми исходными файлами, которые доступны вам и которые вы загрузили.", - "subtitle": "Загружайте и управляйте документами и источниками знаний, которые обеспечивают ваши ответы", + "subtitle": "Контент, по которому DocsGPT может искать и который может цитировать: загрузки, сайты, вики и контент, синхронизированный из ваших подключений.", "label": "Источники", "name": "Название источника", "date": "Дата вектора", @@ -366,7 +366,8 @@ "forbidden": "У вас нет прав на изменение настроек этого источника.", "saveFailed": "Не удалось сохранить настройки. Пожалуйста, попробуйте ещё раз." } - } + }, + "paused": "Синхронизация приостановлена. Переподключите {{name}}, чтобы продолжить." }, "analytics": { "label": "Аналитика", @@ -677,11 +678,11 @@ }, "tools": { "label": "Инструменты", - "subtitle": "Просматривайте, подключайте и настраивайте инструменты и интеграции для ваших агентов", + "subtitle": "Действия, которые могут выполнять ваши агенты. Здесь находятся встроенные инструменты. Инструменты подключённого сервиса сгруппированы под этим сервисом.", "searchPlaceholder": "Поиск...", "addTool": "Добавить инструмент", "noToolsFound": "Инструменты не найдены", - "selectToolSetup": "Выберите инструмент для настройки", + "selectToolSetup": "Добавить инструмент", "settingsIconAlt": "Значок настроек", "configureToolAria": "Настроить {{toolName}}", "toggleToolAria": "Переключить {{toolName}}", @@ -693,8 +694,8 @@ "builtIn": "Встроенный", "authStatus": { "connected": "Подключено", - "needsAuth": "Требуется авторизация", - "configured": "Настроено" + "needsAuth": "Переподключить", + "configured": "Подключено" }, "deleteWarning": "Вы уверены, что хотите удалить инструмент \"{{toolName}}\"?", "unsavedChanges": "У вас есть несохраненные изменения, которые будут потеряны, если вы уйдете без сохранения.", @@ -799,7 +800,11 @@ "maxTimeout": "Максимальный тайм-аут — 300 секунд" }, "headerValuePlaceholder": "например, application/json", - "toolIconTitle": "Значок {{name}}" + "toolIconTitle": "Значок {{name}}", + "groupBuiltIn": "Встроенные", + "groupService": "Из сервиса", + "browseConnectors": "Ищете Notion, Linear или другой сервис? Смотрите коннекторы", + "viaConnection": "через {{name}}" }, "devices": { "label": "Устройства", @@ -1042,13 +1047,109 @@ "subtitle": "Новый секрет для «{{name}}» готов. Старый больше не работает." } } + }, + "connectors": { + "label": "Коннекторы", + "subtitle": "Подключите DocsGPT к сервисам, которыми пользуется ваша команда. Подключение может синхронизировать контент в Источники и давать агентам инструменты.", + "search": "Поиск коннекторов...", + "addCustom": "Добавить свой коннектор", + "custom": { + "custom_mcp": "MCP-сервер", + "custom_openapi": "OpenAPI / REST" + }, + "categoriesLabel": "Фильтр коннекторов по категории", + "categories": { + "all": "Все", + "connected": "Подключённые", + "files": "Файлы и документы", + "knowledge": "Базы знаний", + "dev": "Инструменты разработчика", + "messaging": "Сообщения", + "database": "Базы данных", + "search": "Поиск", + "custom": "Свои" + }, + "capability": { + "sync": "Синхронизация", + "read": "Чтение", + "write": "Запись" + }, + "status": { + "connect": "Подключить", + "connected": "Подключено", + "connectedCount": "Подключено: {{count}}", + "reconnect": "Переподключить", + "needsAdminSetup": "Нужна настройка администратором", + "disabledByAdmin": "Отключено администратором", + "custom": "Свой" + }, + "connectionStatus": { + "connected": "Подключено", + "reconnect_needed": "Нужно переподключить", + "disconnected": "Отключено", + "error": "Ошибка", + "pending": "Ожидание входа" + }, + "publisher": { + "built_in": "Встроенный", + "preset": "Готовый", + "custom": "Свой" + }, + "descriptions": { + "google_drive": "Синхронизирует Документы, Таблицы и PDF в Источники.", + "share_point": "Синхронизирует файлы с сайтов SharePoint и из OneDrive в Источники.", + "confluence": "Синхронизирует пространства и страницы Confluence в Источники.", + "s3": "Синхронизирует документы из бакета S3 в Источники.", + "reddit": "Синхронизирует в Источники посты Reddit по вашим поисковым запросам.", + "brave": "Поиск в интернете и по картинкам через Brave Search API.", + "telegram": "Отправляет сообщения и изображения в чат Telegram.", + "ntfy": "Отправляет push-уведомления через сервер ntfy.", + "postgres": "Читает схему и выполняет SQL в базе данных Postgres.", + "custom_mcp": "Подключает любой удалённый сервер Model Context Protocol.", + "custom_openapi": "Импортирует спецификацию OpenAPI и вызывает её эндпоинты как инструменты." + }, + "empty": "Пока ничего не подключено. Подключите сервис, чтобы синхронизировать его контент или дать агентам новые инструменты.", + "noMatches": "Нет коннекторов, подходящих под запрос.", + "loadFailed": "Не удалось загрузить коннекторы.", + "askAdmin": "Попросите администратора настроить это.", + "setupSettings": "Задайте эти настройки сервера и перезапустите DocsGPT:", + "setupGuide": "Инструкция по настройке", + "unverified": "DocsGPT не проверял этот сервер. Подключайте только серверы, которым доверяете.", + "detail": { + "accounts": "Аккаунты", + "connectedAs": "Подключено как {{account}}", + "connectAnother": "Подключить другой аккаунт", + "sources": "Источники из этого подключения", + "tools": "Инструменты из этого подключения", + "noAccounts": "Аккаунт ещё не подключён.", + "failed": "Не удалось загрузить это подключение.", + "lastSync": "Синхронизировано {{date}} · {{frequency}}", + "paused": "Синхронизация приостановлена", + "toolOn": "Вкл.", + "toolOff": "Выкл.", + "accountMenu": "Действия с аккаунтом", + "disconnect": "Отключить" + }, + "permission": { + "always": "Всегда разрешать", + "ask": "Нужно одобрение", + "off": "Выкл." + }, + "disconnect": { + "title": "Отключить {{name}}?", + "body_zero": "Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", + "body_one": "Ваш {{count}} источник сохранит контент, но перестанет синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", + "body_few": "Ваши {{count}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", + "body_many": "Ваши {{count}} источников сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", + "body_other": "Ваши {{count}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь." + } } }, "modals": { "uploadDoc": { - "label": "Загрузить новый документ", + "label": "Добавить источник", "select": "Выберите способ загрузки документа в DocsGPT", - "selectSource": "Выберите способ добавления источника", + "selectSource": "Добавить источник", "selectedFiles": "Выбранные файлы", "noFilesSelected": "Файлы не выбраны", "file": "Загрузить с устройства", @@ -1061,7 +1162,7 @@ "info": "Пожалуйста, загрузите файлы .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip размером до 25 МБ", "uploadedFiles": "Загруженные файлы", "cancel": "Отмена", - "train": "Тренировка", + "train": "Добавить источник", "create": "Создать", "link": "Ссылка", "urlLink": "URL ссылка", @@ -1194,7 +1295,10 @@ "region": "Регион AWS", "endpointUrl": "Пользовательский URL эндпоинта (необязательно)", "initialContent": "Исходное содержимое (необязательно)" - } + }, + "groupUploadWeb": "Загрузка и веб", + "groupConnection": "Из подключения", + "tileConnectedAs": "Подключено как {{account}}" }, "createAPIKey": { "label": "Создать новый API ключ", @@ -1230,8 +1334,8 @@ "configTool": { "title": "Настройка инструмента", "type": "Тип", - "apiKeyLabel": "API ключ / OAuth", - "apiKeyPlaceholder": "Введите API ключ / OAuth", + "apiKeyLabel": "API-ключ", + "apiKeyPlaceholder": "Введите API-ключ", "addButton": "Добавить инструмент", "closeButton": "Закрыть", "customNamePlaceholder": "Enter custom name (optional)", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 3ca0570b..183deb66 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -97,7 +97,7 @@ }, "sources": { "title": "在這裡您可以管理所有可用的來源檔案以及您上傳的檔案。", - "subtitle": "上傳並管理您的回答所依賴的文件和知識來源", + "subtitle": "DocsGPT 可以搜尋和引用的內容:上傳的檔案、網站、Wiki,以及從連線同步的內容。", "label": "來源", "name": "來源名稱", "date": "向量日期", @@ -353,7 +353,8 @@ "forbidden": "您沒有編輯此來源設定的權限。", "saveFailed": "儲存設定失敗。請再試一次。" } - } + }, + "paused": "同步已暫停。重新連線 {{name}} 以繼續。" }, "analytics": { "label": "分析", @@ -650,11 +651,11 @@ }, "tools": { "label": "工具", - "subtitle": "瀏覽、連接並設定驅動您的智能體的工具和整合", + "subtitle": "代理可以執行的操作。內建工具在這裡。已連線服務提供的工具歸在該服務之下。", "searchPlaceholder": "搜尋工具...", "addTool": "新增工具", "noToolsFound": "找不到工具", - "selectToolSetup": "選擇要設定的工具", + "selectToolSetup": "新增工具", "settingsIconAlt": "設定圖示", "configureToolAria": "設定 {{toolName}}", "toggleToolAria": "切換 {{toolName}}", @@ -666,8 +667,8 @@ "builtIn": "內建", "authStatus": { "connected": "已連線", - "needsAuth": "需要驗證", - "configured": "已設定" + "needsAuth": "重新連線", + "configured": "已連線" }, "deleteWarning": "您確定要刪除工具 \"{{toolName}}\" 嗎?", "unsavedChanges": "您有未儲存的變更,如果不儲存就離開將會遺失。", @@ -772,7 +773,11 @@ "maxTimeout": "逾時時間最長為 300 秒" }, "headerValuePlaceholder": "例如:application/json", - "toolIconTitle": "{{name}} 圖示" + "toolIconTitle": "{{name}} 圖示", + "groupBuiltIn": "內建", + "groupService": "來自服務", + "browseConnectors": "在找 Notion、Linear 或其他服務嗎?瀏覽連接器", + "viaConnection": "透過 {{name}}" }, "devices": { "label": "裝置", @@ -1001,13 +1006,106 @@ "subtitle": "「{{name}}」的新密鑰已就緒,舊密鑰已失效。" } } + }, + "connectors": { + "label": "連接器", + "subtitle": "將 DocsGPT 連接到團隊使用的服務。一個連線可以把內容同步到來源,也可以為代理提供工具。", + "search": "搜尋連接器...", + "addCustom": "新增自訂連接器", + "custom": { + "custom_mcp": "MCP 伺服器", + "custom_openapi": "OpenAPI / REST" + }, + "categoriesLabel": "依類別篩選連接器", + "categories": { + "all": "全部", + "connected": "已連線", + "files": "檔案和文件", + "knowledge": "知識庫", + "dev": "開發者工具", + "messaging": "訊息", + "database": "資料庫", + "search": "搜尋", + "custom": "自訂" + }, + "capability": { + "sync": "同步", + "read": "讀取", + "write": "寫入" + }, + "status": { + "connect": "連線", + "connected": "已連線", + "connectedCount": "已連線 {{count}} 個", + "reconnect": "重新連線", + "needsAdminSetup": "需要管理員設定", + "disabledByAdmin": "已被管理員停用", + "custom": "自訂" + }, + "connectionStatus": { + "connected": "已連線", + "reconnect_needed": "需要重新連線", + "disconnected": "已中斷連線", + "error": "錯誤", + "pending": "等待登入" + }, + "publisher": { + "built_in": "內建", + "preset": "預設", + "custom": "自訂" + }, + "descriptions": { + "google_drive": "將文件、試算表和 PDF 同步到來源。", + "share_point": "將 SharePoint 網站和 OneDrive 中的檔案同步到來源。", + "confluence": "將 Confluence 空間和頁面同步到來源。", + "s3": "將 S3 儲存貯體中的文件同步到來源。", + "reddit": "將符合搜尋條件的 Reddit 貼文同步到來源。", + "brave": "使用 Brave Search API 搜尋網頁和圖片。", + "telegram": "傳送訊息和圖片到 Telegram 聊天。", + "ntfy": "透過 ntfy 伺服器傳送推播通知。", + "postgres": "讀取 Postgres 資料庫的結構並執行 SQL。", + "custom_mcp": "連接任何遠端 Model Context Protocol 伺服器。", + "custom_openapi": "匯入 OpenAPI 規格,並將其端點作為工具呼叫。" + }, + "empty": "尚未連線任何服務。連線一個服務以同步其內容,或為代理提供新工具。", + "noMatches": "沒有符合搜尋的連接器。", + "loadFailed": "無法載入連接器。", + "askAdmin": "請管理員完成設定。", + "setupSettings": "設定以下伺服器設定,然後重新啟動 DocsGPT:", + "setupGuide": "設定指南", + "unverified": "此伺服器未經 DocsGPT 審查。請只連接你信任的伺服器。", + "detail": { + "accounts": "帳號", + "connectedAs": "已以 {{account}} 連線", + "connectAnother": "連線其他帳號", + "sources": "來自此連線的來源", + "tools": "來自此連線的工具", + "noAccounts": "尚未連線帳號。", + "failed": "無法載入此連線。", + "lastSync": "已於 {{date}} 同步 · {{frequency}}", + "paused": "同步已暫停", + "toolOn": "開啟", + "toolOff": "關閉", + "accountMenu": "帳號操作", + "disconnect": "中斷連線" + }, + "permission": { + "always": "一律允許", + "ask": "需要核准", + "off": "關閉" + }, + "disconnect": { + "title": "要中斷 {{name}} 的連線嗎?", + "body_zero": "在重新連線之前,此連線的工具將無法使用。", + "body_other": "你的 {{count}} 個來源會保留內容,但會停止同步。在重新連線之前,此連線的工具將無法使用。" + } } }, "modals": { "uploadDoc": { - "label": "上傳新文件", + "label": "新增來源", "select": "選擇如何將文件上傳到 DocsGPT", - "selectSource": "選擇新增來源的方式", + "selectSource": "新增來源", "selectedFiles": "已選擇的檔案", "noFilesSelected": "未選擇檔案", "file": "從檔案", @@ -1020,7 +1118,7 @@ "info": "請上傳限制為25MB的.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zip檔案", "uploadedFiles": "已上傳檔案", "cancel": "取消", - "train": "訓練", + "train": "新增來源", "create": "建立", "link": "連結", "urlLink": "URL 連結", @@ -1153,7 +1251,10 @@ "region": "AWS 區域", "endpointUrl": "自訂端點 URL(選填)", "initialContent": "初始內容(選填)" - } + }, + "groupUploadWeb": "上傳和網頁", + "groupConnection": "來自連線", + "tileConnectedAs": "已以 {{account}} 連線" }, "createAPIKey": { "label": "建立新的 API 金鑰", @@ -1189,8 +1290,8 @@ "configTool": { "title": "工具設定", "type": "類型", - "apiKeyLabel": "API 金鑰 / OAuth", - "apiKeyPlaceholder": "輸入 API 金鑰 / OAuth", + "apiKeyLabel": "API 金鑰", + "apiKeyPlaceholder": "輸入 API 金鑰", "addButton": "新增工具", "closeButton": "關閉", "customNamePlaceholder": "Enter custom name (optional)", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index d41815c1..29b4880b 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -97,7 +97,7 @@ }, "sources": { "title": "在这里您可以管理所有可用的源文件以及您上传的文件。", - "subtitle": "上传并管理您的回答所依赖的文档和知识来源", + "subtitle": "DocsGPT 可以搜索和引用的内容:上传的文件、网站、Wiki,以及从连接同步的内容。", "label": "来源", "name": "来源名称", "date": "向量日期", @@ -353,7 +353,8 @@ "forbidden": "您没有权限编辑此来源的配置。", "saveFailed": "保存配置失败,请重试。" } - } + }, + "paused": "同步已暂停。重新连接 {{name}} 以继续。" }, "analytics": { "label": "分析", @@ -650,11 +651,11 @@ }, "tools": { "label": "工具", - "subtitle": "浏览、连接并配置驱动您的智能体的工具和集成", + "subtitle": "智能体可以执行的操作。内置工具在这里。已连接服务提供的工具归在该服务下。", "searchPlaceholder": "搜索工具...", "addTool": "添加工具", "noToolsFound": "未找到工具", - "selectToolSetup": "选择要设置的工具", + "selectToolSetup": "添加工具", "settingsIconAlt": "设置图标", "configureToolAria": "配置 {{toolName}}", "toggleToolAria": "切换 {{toolName}}", @@ -666,8 +667,8 @@ "builtIn": "内置", "authStatus": { "connected": "已连接", - "needsAuth": "需要认证", - "configured": "已配置" + "needsAuth": "重新连接", + "configured": "已连接" }, "deleteWarning": "您确定要删除工具 \"{{toolName}}\" 吗?", "unsavedChanges": "您有未保存的更改,如果不保存就离开将会丢失。", @@ -772,7 +773,11 @@ "maxTimeout": "超时时间最长为 300 秒" }, "headerValuePlaceholder": "例如:application/json", - "toolIconTitle": "{{name}} 图标" + "toolIconTitle": "{{name}} 图标", + "groupBuiltIn": "内置", + "groupService": "来自服务", + "browseConnectors": "在找 Notion、Linear 或其他服务?浏览连接器", + "viaConnection": "通过 {{name}}" }, "devices": { "label": "设备", @@ -1001,13 +1006,106 @@ "subtitle": "“{{name}}”的新密钥已就绪,旧密钥已失效。" } } + }, + "connectors": { + "label": "连接器", + "subtitle": "将 DocsGPT 连接到团队使用的服务。一个连接可以把内容同步到来源,也可以为智能体提供工具。", + "search": "搜索连接器...", + "addCustom": "添加自定义连接器", + "custom": { + "custom_mcp": "MCP 服务器", + "custom_openapi": "OpenAPI / REST" + }, + "categoriesLabel": "按类别筛选连接器", + "categories": { + "all": "全部", + "connected": "已连接", + "files": "文件和文档", + "knowledge": "知识库", + "dev": "开发者工具", + "messaging": "消息", + "database": "数据库", + "search": "搜索", + "custom": "自定义" + }, + "capability": { + "sync": "同步", + "read": "读取", + "write": "写入" + }, + "status": { + "connect": "连接", + "connected": "已连接", + "connectedCount": "已连接 {{count}} 个", + "reconnect": "重新连接", + "needsAdminSetup": "需要管理员设置", + "disabledByAdmin": "已被管理员停用", + "custom": "自定义" + }, + "connectionStatus": { + "connected": "已连接", + "reconnect_needed": "需要重新连接", + "disconnected": "已断开", + "error": "错误", + "pending": "等待登录" + }, + "publisher": { + "built_in": "内置", + "preset": "预设", + "custom": "自定义" + }, + "descriptions": { + "google_drive": "将文档、表格和 PDF 同步到来源。", + "share_point": "将 SharePoint 站点和 OneDrive 中的文件同步到来源。", + "confluence": "将 Confluence 空间和页面同步到来源。", + "s3": "将 S3 存储桶中的文档同步到来源。", + "reddit": "将符合搜索条件的 Reddit 帖子同步到来源。", + "brave": "使用 Brave Search API 搜索网页和图片。", + "telegram": "向 Telegram 聊天发送消息和图片。", + "ntfy": "通过 ntfy 服务器发送推送通知。", + "postgres": "读取 Postgres 数据库的结构并执行 SQL。", + "custom_mcp": "连接任意远程 Model Context Protocol 服务器。", + "custom_openapi": "导入 OpenAPI 规范,并将其端点作为工具调用。" + }, + "empty": "尚未连接任何服务。连接一个服务以同步其内容,或为智能体提供新工具。", + "noMatches": "没有与搜索匹配的连接器。", + "loadFailed": "无法加载连接器。", + "askAdmin": "请让管理员完成设置。", + "setupSettings": "设置以下服务器配置,然后重启 DocsGPT:", + "setupGuide": "设置指南", + "unverified": "此服务器未经 DocsGPT 审核。请只连接你信任的服务器。", + "detail": { + "accounts": "账号", + "connectedAs": "已作为 {{account}} 连接", + "connectAnother": "连接其他账号", + "sources": "来自此连接的来源", + "tools": "来自此连接的工具", + "noAccounts": "尚未连接账号。", + "failed": "无法加载此连接。", + "lastSync": "已于 {{date}} 同步 · {{frequency}}", + "paused": "同步已暂停", + "toolOn": "开启", + "toolOff": "关闭", + "accountMenu": "账号操作", + "disconnect": "断开连接" + }, + "permission": { + "always": "始终允许", + "ask": "需要批准", + "off": "关闭" + }, + "disconnect": { + "title": "断开 {{name}}?", + "body_zero": "在重新连接之前,此连接的工具将无法使用。", + "body_other": "你的 {{count}} 个来源会保留内容,但将停止同步。在重新连接之前,此连接的工具将无法使用。" + } } }, "modals": { "uploadDoc": { - "label": "上传新文档", + "label": "添加来源", "select": "选择如何将文档上传到 DocsGPT", - "selectSource": "选择添加源的方式", + "selectSource": "添加来源", "selectedFiles": "已选择的文件", "noFilesSelected": "未选择文件", "file": "从设备上传", @@ -1020,7 +1118,7 @@ "info": "请上传限制为25MB的.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zip文件", "uploadedFiles": "已上传文件", "cancel": "取消", - "train": "训练", + "train": "添加来源", "create": "创建", "link": "链接", "urlLink": "URL 链接", @@ -1153,7 +1251,10 @@ "region": "AWS 区域", "endpointUrl": "自定义端点 URL(可选)", "initialContent": "初始内容(可选)" - } + }, + "groupUploadWeb": "上传和网页", + "groupConnection": "来自连接", + "tileConnectedAs": "已作为 {{account}} 连接" }, "createAPIKey": { "label": "创建新的 API 密钥", @@ -1189,8 +1290,8 @@ "configTool": { "title": "工具配置", "type": "类型", - "apiKeyLabel": "API 密钥 / OAuth", - "apiKeyPlaceholder": "输入 API 密钥 / OAuth", + "apiKeyLabel": "API 密钥", + "apiKeyPlaceholder": "输入 API 密钥", "addButton": "添加工具", "closeButton": "关闭", "customNamePlaceholder": "Enter custom name (optional)", diff --git a/frontend/src/modals/ConfirmationModal.tsx b/frontend/src/modals/ConfirmationModal.tsx index 4e986a03..1cfbdfe6 100644 --- a/frontend/src/modals/ConfirmationModal.tsx +++ b/frontend/src/modals/ConfirmationModal.tsx @@ -5,6 +5,7 @@ import { ActiveState } from '../models/misc'; export default function ConfirmationModal({ message, + description, modalState, setModalState, submitLabel, @@ -14,6 +15,8 @@ export default function ConfirmationModal({ variant = 'default', }: { message: string; + /** A muted line under the title that says what the action does. */ + description?: string; modalState: ActiveState; setModalState: (state: ActiveState) => void; submitLabel: string; @@ -41,6 +44,7 @@ export default function ConfirmationModal({ if (!open) setModalState('INACTIVE'); }} title={message} + description={description} footer={
+ {!server?.preset && ( + + + )} {server?.has_encrypted_credentials && formData.auth_type !== 'oauth' && ( diff --git a/frontend/src/models/misc.ts b/frontend/src/models/misc.ts index 5c118244..2c4c5647 100644 --- a/frontend/src/models/misc.ts +++ b/frontend/src/models/misc.ts @@ -85,6 +85,8 @@ export type Doc = { // Access level when shared via a team: 'viewer' (read-only) or 'editor' // (full write). Null/absent for sources the caller owns. team_access?: 'viewer' | 'editor' | null; + // The connection a synced source comes from (Google Drive account, S3 keys). + connectionId?: string | null; }; export type GetDocsResponse = { diff --git a/frontend/src/navigation/sections.ts b/frontend/src/navigation/sections.ts index 5eb73b39..0334f18c 100644 --- a/frontend/src/navigation/sections.ts +++ b/frontend/src/navigation/sections.ts @@ -11,6 +11,7 @@ import { LayoutDashboard, LayoutGrid, LayoutTemplate, + Plug, ScrollText, Settings2, ShieldCheck, @@ -115,6 +116,12 @@ export const SETTINGS_SECTION: Section = { labelKey: 'settings.sources.label', icon: Database, }, + { + key: 'connectors', + path: '/settings/connectors', + labelKey: 'settings.connectors.label', + icon: Plug, + }, { key: 'tools', path: '/settings/tools', diff --git a/frontend/src/settings/Connectors.test.tsx b/frontend/src/settings/Connectors.test.tsx new file mode 100644 index 00000000..112c86c2 --- /dev/null +++ b/frontend/src/settings/Connectors.test.tsx @@ -0,0 +1,217 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { defaultValue?: string; count?: number }) => + opts?.count !== undefined ? `${key}:${opts.count}` : key, + }), +})); + +const service = vi.hoisted(() => ({ + getCatalog: vi.fn(), + listConnections: vi.fn(), + getConnection: vi.fn(), + disconnect: vi.fn(), +})); +vi.mock('../api/services/connectorsService', () => ({ default: service })); + +import connectorsReducer from '../connectors/connectorsSlice'; +import type { ConnectorDefinition } from '../connectors/types'; +import Connectors from './Connectors'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const definition = ( + overrides: Partial, +): ConnectorDefinition => ({ + key: 'telegram', + name: 'Telegram', + description: 'Send messages.', + icon: 'tool_telegram', + category: 'messaging', + auth_kind: 'api_key', + capabilities: ['write'], + credential_fields: [], + setup_fields: [], + sync_ingestor: null, + default_sync_frequency: 'weekly', + tool_templates: ['telegram'], + setup: { tools: 'auto', sync: 'off' }, + mcp_url: null, + publisher: 'built_in', + docs_url: null, + oauth_scopes: [], + available: true, + disabled: false, + needs_setup: false, + missing_settings: [], + connected_count: 0, + connection_count: 0, + status: null, + state: 'available', + credential_policy: 'choose', + ...overrides, +}); + +const CATALOG = [ + definition({}), + definition({ + key: 'google_drive', + name: 'Google Drive', + category: 'files', + capabilities: ['sync'], + state: 'connected', + connected_count: 2, + connection_count: 2, + status: 'connected', + }), + definition({ + key: 'confluence', + name: 'Confluence', + category: 'knowledge', + capabilities: ['sync'], + state: 'reconnect', + connection_count: 1, + status: 'reconnect_needed', + }), + definition({ + key: 'share_point', + name: 'SharePoint', + category: 'files', + state: 'needs_setup', + available: false, + needs_setup: true, + }), + definition({ + key: 'brave', + name: 'Brave Search', + category: 'search', + state: 'disabled', + available: false, + disabled: true, + }), + definition({ + key: 'custom_mcp', + name: 'MCP server', + category: 'custom', + publisher: 'custom', + auth_kind: 'mcp', + state: 'custom', + }), +]; + +describe('Connectors page', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + service.getCatalog.mockResolvedValue({ + success: true, + connectors: CATALOG, + }); + service.listConnections.mockResolvedValue({ + success: true, + connections: [], + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async (path = '/settings/connectors') => { + const store = configureStore({ + reducer: { + connectors: connectorsReducer, + preference: (state = { token: null }) => state, + }, + }); + await act(async () => { + root.render( + + + + + , + ); + }); + }; + + const card = (key: string) => + container.querySelector( + `[data-testid="connector-card-${key}"]`, + ); + + it('renders every card state', async () => { + await render(); + expect(card('telegram')!.textContent).toContain( + 'settings.connectors.status.connect', + ); + expect(card('google_drive')!.textContent).toContain( + 'settings.connectors.status.connectedCount:2', + ); + expect(card('confluence')!.textContent).toContain( + 'settings.connectors.status.reconnect', + ); + expect(card('share_point')!.textContent).toContain( + 'settings.connectors.status.needsAdminSetup', + ); + expect(card('brave')!.textContent).toContain( + 'settings.connectors.status.disabledByAdmin', + ); + expect(card('brave')!.disabled).toBe(true); + expect(card('custom_mcp')!.textContent).toContain( + 'settings.connectors.publisher.custom', + ); + }); + + it('sorts connections that need attention first', async () => { + await render(); + const keys = Array.from( + container.querySelectorAll('[data-testid^="connector-card-"]'), + ).map((el) => el.getAttribute('data-testid')); + expect(keys[0]).toBe('connector-card-confluence'); + expect(keys[1]).toBe('connector-card-google_drive'); + expect(keys[keys.length - 1]).toBe('connector-card-brave'); + }); + + it('shows capability chips', async () => { + await render(); + expect(card('google_drive')!.textContent).toContain( + 'settings.connectors.capability.sync', + ); + expect(card('telegram')!.textContent).toContain( + 'settings.connectors.capability.write', + ); + }); + + it('filters to connected services', async () => { + await render('/settings/connectors?filter=connected'); + expect(card('google_drive')).not.toBeNull(); + expect(card('confluence')).not.toBeNull(); + expect(card('telegram')).toBeNull(); + }); + + it('shows the empty state when nothing is connected', async () => { + service.getCatalog.mockResolvedValue({ + success: true, + connectors: [definition({})], + }); + await render('/settings/connectors?filter=connected'); + expect(container.textContent).toContain('settings.connectors.empty'); + }); + + it('shows a retry when the catalog fails to load', async () => { + service.getCatalog.mockResolvedValue({ success: false }); + await render(); + expect(container.textContent).toContain('settings.connectors.loadFailed'); + }); +}); diff --git a/frontend/src/settings/Connectors.tsx b/frontend/src/settings/Connectors.tsx new file mode 100644 index 00000000..cf2bdfd2 --- /dev/null +++ b/frontend/src/settings/Connectors.tsx @@ -0,0 +1,220 @@ +import { ChevronDown, Plus } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { useSearchParams } from 'react-router-dom'; + +import PageToolbar from '../components/PageToolbar'; +import SearchInput from '../components/SearchInput'; +import SkeletonLoader from '../components/SkeletonLoader'; +import { Button } from '../components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../components/ui/dropdown-menu'; +import { EmptyState } from '../components/ui/empty-state'; +import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group'; +import ConnectionDrawer from '../connectors/ConnectionDrawer'; +import ConnectorCard from '../connectors/ConnectorCard'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { + loadConnectors, + selectConnectorCatalog, + selectConnectorsFailed, + selectConnectorsLoaded, +} from '../connectors/connectorsSlice'; +import { connectorDescription, connectorName } from '../connectors/i18n'; +import type { ConnectorDefinition } from '../connectors/types'; +import useConnectorLauncher from '../connectors/useConnectorLauncher'; +import { selectToken } from '../preferences/preferenceSlice'; +import type { AppDispatch } from '../store'; + +const FILTERS = [ + 'all', + 'connected', + 'files', + 'knowledge', + 'dev', + 'messaging', + 'database', + 'search', + 'custom', +] as const; +type Filter = (typeof FILTERS)[number]; + +// Connected and needing attention first, then what can be connected, then +// what an admin still has to set up. +const STATE_ORDER: Record = { + reconnect: 0, + connected: 1, + available: 2, + custom: 3, + needs_setup: 4, + disabled: 5, +}; + +const isConnected = (connector: ConnectorDefinition) => + connector.connection_count > 0; + +export default function Connectors() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const token = useSelector(selectToken); + const catalog = useSelector(selectConnectorCatalog); + const loaded = useSelector(selectConnectorsLoaded); + const failed = useSelector(selectConnectorsFailed); + const [searchParams, setSearchParams] = useSearchParams(); + const [search, setSearch] = useState(''); + const initialFilter = searchParams.get('filter') as Filter | null; + const [filter, setFilter] = useState( + initialFilter && FILTERS.includes(initialFilter) ? initialFilter : 'all', + ); + const [openKey, setOpenKey] = useState( + searchParams.get('connector'), + ); + const { launch, modals } = useConnectorLauncher(); + + useEffect(() => { + dispatch(loadConnectors({ token })); + }, [dispatch, token]); + + const custom = catalog.filter((c) => c.publisher === 'custom'); + const openConnector = catalog.find((c) => c.key === openKey) ?? null; + + const visible = useMemo(() => { + const query = search.trim().toLowerCase(); + return catalog + .filter((connector) => { + if (filter === 'connected') return isConnected(connector); + if (filter !== 'all') return connector.category === filter; + return true; + }) + .filter( + (connector) => + !query || + connectorName(t, connector).toLowerCase().includes(query) || + connectorDescription(t, connector).toLowerCase().includes(query), + ) + .sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]); + }, [catalog, filter, search, t]); + + const open = (connector: ConnectorDefinition) => { + if (connector.state === 'available' || connector.state === 'custom') { + launch(connector); + return; + } + setOpenKey(connector.key); + }; + + const closeDrawer = () => { + setOpenKey(null); + if (searchParams.has('connector')) { + searchParams.delete('connector'); + setSearchParams(searchParams, { replace: true }); + } + }; + + return ( +
+ setSearch(e.target.value)} + /> + } + action={ + + + + + + {custom.map((connector) => ( + launch(connector)} + > + + {connectorName(t, connector)} + + ))} + + + } + > + value && setFilter(value as Filter)} + aria-label={t('settings.connectors.categoriesLabel')} + className="mb-6" + > + {FILTERS.map((key) => ( + + {t(`settings.connectors.categories.${key}`)} + + ))} + + + + {!loaded && !failed ? ( +
+ +
+ ) : failed && catalog.length === 0 ? ( + dispatch(loadConnectors({ token }))} + > + {t('retry')} + + } + /> + ) : visible.length === 0 ? ( + + ) : ( +
+ {visible.map((connector) => ( + + ))} +
+ )} + + { + closeDrawer(); + launch(connector); + }} + /> + {modals} +
+ ); +} diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index 38aa5297..a95c53ed 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -48,6 +48,13 @@ import { import { formatDate } from '../utils/dateTimeUtils'; import FileTree from '../components/FileTree'; import ConnectorTree from '../components/ConnectorTree'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { + loadConnectors, + selectConnections, + selectConnectorsLoaded, +} from '../connectors/connectorsSlice'; +import type { AppDispatch } from '../store'; import Chunks from '../components/Chunks'; import WikiViewer from '../components/WikiViewer'; import GraphView from '../components/GraphView'; @@ -78,9 +85,15 @@ export default function Sources({ handleDeleteDocument, }: DocumentsProps) { const { t } = useTranslation(); - const dispatch = useDispatch(); + const dispatch = useDispatch(); const token = useSelector(selectToken); const uploadTasks = useSelector(selectUploadTasks); + const connections = useSelector(selectConnections); + const connectorsLoaded = useSelector(selectConnectorsLoaded); + + useEffect(() => { + if (!connectorsLoaded) dispatch(loadConnectors({ token })); + }, [connectorsLoaded, dispatch, token]); const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); @@ -620,6 +633,12 @@ export default function Sources({
{currentDocuments.map((document, index) => { const docId = document.id ? document.id.toString() : ''; + const connection = document.connectionId + ? connections.find((c) => c.id === document.connectionId) + : undefined; + const paused = + connection?.status === 'reconnect_needed' || + connection?.status === 'disconnected'; return (
@@ -670,6 +689,17 @@ export default function Sources({ : t('teamAccess.viewer')} )} + {connection && paused && ( + + {t('settings.connectors.detail.paused')} + + )} {document.ingestStatus === 'failed' && ( {t('settings.sources.ingestFailed')} @@ -713,6 +743,23 @@ export default function Sources({ ); })()} + {connection && ( + + + + {t('settings.tools.viaConnection', { + name: connection.account_label, + interpolation: { escapeValue: false }, + })} + + + )} {document.date ? formatDate(document.date) : ''} diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index eb408ad4..1e7c6731 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -1,7 +1,8 @@ import { Pencil, RefreshCw, Trash2, Users } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import devicesService from '../api/services/devicesService'; import userService from '../api/services/userService'; @@ -15,12 +16,18 @@ import { Card, CardDescription, CardTitle } from '../components/ui/card'; import { Switch } from '../components/ui/switch'; import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu'; import { EmptyState } from '../components/ui/empty-state'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { + loadConnectors, + selectConnections, +} from '../connectors/connectorsSlice'; import { useLoaderState } from '../hooks'; import AddToolModal from '../modals/AddToolModal'; import ConfirmationModal from '../modals/ConfirmationModal'; import MCPServerModal from '../modals/MCPServerModal'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; +import type { AppDispatch } from '../store'; import ShareToTeamModal from '../teams/ShareToTeamModal'; import RemoteDeviceConfig from './RemoteDeviceConfig'; import ToolConfig from './ToolConfig'; @@ -29,6 +36,10 @@ import { APIToolType, UserToolType } from './types'; export default function Tools() { const { t } = useTranslation(); const token = useSelector(selectToken); + const dispatch = useDispatch(); + const connections = useSelector(selectConnections); + const location = useLocation(); + const navigate = useNavigate(); const [searchTerm, setSearchTerm] = React.useState(''); const [addToolModalState, setAddToolModalState] = @@ -236,7 +247,18 @@ export default function Tools() { React.useEffect(() => { getUserTools(); fetchMcpStatuses(); + dispatch(loadConnectors({ token })); }, []); + + // The Connectors page creates an OpenAPI tool and sends its id here so + // the spec import opens straight away. + const openToolId = (location.state as { openToolId?: string } | null) + ?.openToolId; + React.useEffect(() => { + if (!openToolId) return; + handleToolAdded(openToolId); + navigate(location.pathname, { replace: true, state: null }); + }, [openToolId]); return (
{selectedTool ? ( @@ -363,6 +385,37 @@ export default function Tools() { > {tool.customName || tool.displayName} + {(() => { + const connection = tool.connection_id + ? connections.find( + (c) => c.id === tool.connection_id, + ) + : undefined; + if (!connection) return null; + return ( + + ); + })()} } /> } /> + } /> } /> getDefaultMiddleware().concat(