diff --git a/frontend/src/admin/Connectors.test.tsx b/frontend/src/admin/Connectors.test.tsx new file mode 100644 index 00000000..265a4315 --- /dev/null +++ b/frontend/src/admin/Connectors.test.tsx @@ -0,0 +1,158 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const getAdmin = vi.fn(); +const updateAdmin = vi.fn(); +vi.mock('../api/services/connectorsService', () => ({ + default: { + getAdmin: (...args: unknown[]) => getAdmin(...args), + updateAdmin: (...args: unknown[]) => updateAdmin(...args), + }, +})); + +import { prefSlice } from '../preferences/preferenceSlice'; +import Connectors from './Connectors'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const connector = (overrides: Record = {}) => ({ + key: 'google_drive', + name: 'Google Drive', + icon: 'google-drive', + publisher: 'built_in', + auth_kind: 'oauth', + enabled: true, + credential_mode: 'choose', + configured: false, + required_settings: [ + { name: 'GOOGLE_CLIENT_ID', set: true }, + { name: 'GOOGLE_CLIENT_SECRET', set: false }, + ], + connection_count: 3, + docs_url: null, + mcp_url: null, + ...overrides, +}); + +const payload = (overrides: Record = {}) => ({ + success: true, + connectors: [ + connector(), + connector({ + key: 'mcp_notion', + name: 'Notion', + icon: 'notion', + publisher: 'preset', + auth_kind: 'mcp_oauth', + configured: true, + required_settings: [], + connection_count: 0, + }), + ], + allow_custom_mcp: true, + default_encryption_key: false, + oauth_redirect_uri: 'https://docs.example/api/connectors/callback', + mcp_redirect_uri: 'https://docs.example/api/mcp_server/callback', + ...overrides, +}); + +describe('Admin Connectors', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + getAdmin.mockReset(); + updateAdmin.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const render = async () => { + const store = configureStore({ + reducer: { preference: prefSlice.reducer }, + }); + await act(async () => { + root.render( + + + , + ); + }); + }; + + it('lists each connector with its setup state and redirect URIs', async () => { + getAdmin.mockResolvedValue(payload()); + await render(); + const rows = Array.from(container.querySelectorAll('tbody tr')); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain('Google Drive'); + expect(rows[0].textContent).toContain('Needs setup'); + expect(rows[0].textContent).toContain('Setup guide'); + expect(rows[1].textContent).toContain('Preset'); + expect(rows[1].textContent).toContain('Ready'); + expect(rows[1].textContent).not.toContain('Setup guide'); + expect(container.textContent).toContain( + 'https://docs.example/api/connectors/callback', + ); + }); + + it('warns when credentials use the default encryption key', async () => { + getAdmin.mockResolvedValue(payload({ default_encryption_key: true })); + await render(); + expect(container.textContent).toContain('admin.connectors.defaultKey'); + expect(container.textContent).toContain('docsgpt connectors reencrypt'); + }); + + it('saves a connector toggle as a policy', async () => { + getAdmin.mockResolvedValue(payload()); + updateAdmin.mockResolvedValue( + payload({ + connectors: [connector({ enabled: false })], + }), + ); + await render(); + const toggle = container.querySelector( + '[aria-label="Google Drive enabled"]', + )!; + await act(async () => toggle.click()); + expect(updateAdmin).toHaveBeenCalledWith( + { policies: { google_drive: { enabled: false } } }, + null, + ); + expect( + container + .querySelector('[aria-label="Google Drive enabled"]') + ?.getAttribute('aria-checked'), + ).toBe('false'); + }); + + it('shows an error and keeps the page when a save fails', async () => { + getAdmin.mockResolvedValue(payload()); + updateAdmin.mockResolvedValue({ success: false }); + await render(); + await act(async () => + container.querySelector('#allow-custom-mcp')!.click(), + ); + expect(updateAdmin).toHaveBeenCalledWith({ allow_custom_mcp: false }, null); + expect(container.textContent).toContain('Could not save the change.'); + expect(container.querySelectorAll('tbody tr')).toHaveLength(2); + }); + + it('offers a retry when loading fails', async () => { + getAdmin.mockRejectedValue(new Error('offline')); + await render(); + expect(container.textContent).toContain('Failed to load connectors.'); + }); +}); diff --git a/frontend/src/admin/Connectors.tsx b/frontend/src/admin/Connectors.tsx new file mode 100644 index 00000000..b00d4d14 --- /dev/null +++ b/frontend/src/admin/Connectors.tsx @@ -0,0 +1,375 @@ +import { ExternalLink, TriangleAlert } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import connectorsService from '../api/services/connectorsService'; +import CopyButton from '../components/CopyButton'; +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 { LoadingState } from '../components/ui/loading-state'; +import { Modal } from '../components/ui/modal'; +import { SectionHeader } from '../components/ui/section-header'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; +import { SettingRow, SettingRows } from '../components/ui/setting-row'; +import { Switch } from '../components/ui/switch'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { selectToken } from '../preferences/preferenceSlice'; +import { LoadError, fmtNumber } from './AdminUI'; + +type Policy = 'choose' | 'owner' | 'member'; + +type AdminConnector = { + key: string; + name: string; + icon: string; + publisher: 'built_in' | 'preset' | 'custom'; + auth_kind: string; + enabled: boolean; + credential_mode: Policy; + configured: boolean; + required_settings: { name: string; set: boolean }[]; + connection_count: number; + docs_url: string | null; + mcp_url: string | null; +}; + +type AdminConnectorsData = { + success: boolean; + connectors: AdminConnector[]; + allow_custom_mcp: boolean; + default_encryption_key: boolean; + oauth_redirect_uri: string; + mcp_redirect_uri: string; +}; + +const POLICY_LABELS: Record = { + choose: 'Let owners choose', + member: "Always each member's own account", + owner: "Always the owner's account", +}; + +function CodeRow({ value }: { value: string }) { + return ( + +
+        {value}
+      
+ +
+ ); +} + +function SetupGuide({ + connector, + redirectUri, + onClose, +}: { + connector: AdminConnector; + redirectUri: string; + onClose: () => void; +}) { + return ( + !open && onClose()} + title={`Set up ${connector.name}`} + description="Register DocsGPT as an OAuth app with the provider, then set these server settings and restart the API and the worker." + footer={ + + } + > +
+
+ + +
+
+ +
    + {connector.required_settings.map((setting) => ( +
  • + {setting.name} + + {setting.set ? 'Set' : 'Missing'} + +
  • + ))} +
+
+ {connector.key === 'google_drive' && ( + + + + Publish the Google OAuth app (or use an internal Workspace app). + Apps left in Testing get refresh tokens that expire after seven + days, which stops background sync. + + + )} + {connector.docs_url && ( + + )} +
+
+ ); +} + +/** + * Admin > Connectors: which connectors members may use, whose account a + * shared tool runs with, and what each OAuth connector still needs from the + * server. English only, like the rest of the admin pages. + */ +export default function Connectors() { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [saveError, setSaveError] = useState(false); + const [guide, setGuide] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + setData(await connectorsService.getAdmin(token)); + } catch { + setData({ success: false } as AdminConnectorsData); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + load(); + }, [load]); + + const save = async (body: { + policies?: Record; + allow_custom_mcp?: boolean; + }) => { + setSaveError(false); + try { + const next = await connectorsService.updateAdmin(body, token); + if (next?.success) setData(next); + else setSaveError(true); + } catch { + setSaveError(true); + } + }; + + if (data === null && loading) return ; + if (!data?.success) + return ; + + return ( +
+

+ Choose which connectors members can use and whose account a shared tool + runs with. Connectors that need server settings stay listed for members + as Needs admin setup until you add them. +

+ + {data.default_encryption_key && ( + + + {t('admin.connectors.defaultKey')} + + Stored credentials are encrypted with ENCRYPTION_SECRET_KEY, which + still has its public default value. Set your own, keep the old one + in ENCRYPTION_SECRET_KEY_PREVIOUS and run{' '} + + docsgpt connectors reencrypt + + . + + + )} + {saveError && ( + + + Could not save the change. + + )} + + + + + save({ allow_custom_mcp: checked === true }) + } + /> + + + +
+ +

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

+
+
+ + Google Drive, SharePoint and Confluence + + +
+
+ MCP servers + +
+
+
+ +
+ + + + + + Connector + Status + Connections + Enabled + Shared tools use + + + + + {data.connectors.map((connector) => ( + + + + + {connector.name} + {connector.publisher !== 'built_in' && ( + + {connector.publisher === 'preset' + ? 'Preset' + : 'Custom'} + + )} + + + + + {connector.configured ? 'Ready' : 'Needs setup'} + + + + {fmtNumber(connector.connection_count)} + + + + save({ + policies: { + [connector.key]: { enabled: checked === true }, + }, + }) + } + /> + + + + + + {connector.required_settings.length > 0 && ( + + )} + + + ))} + +
+
+
+ + {guide && ( + setGuide(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/admin/index.tsx b/frontend/src/admin/index.tsx index 5a39bb09..296ebb51 100644 --- a/frontend/src/admin/index.tsx +++ b/frontend/src/admin/index.tsx @@ -8,6 +8,7 @@ import Admins from './Admins'; import Activity from './Activity'; import Overview from './Overview'; import Quotas from './Quotas'; +import Connectors from './Connectors'; import Usage from './Usage'; import Users from './Users'; @@ -39,6 +40,7 @@ export default function Admin() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/agents/NewAgent.test.tsx b/frontend/src/agents/NewAgent.test.tsx index 55d0b937..306ad6b7 100644 --- a/frontend/src/agents/NewAgent.test.tsx +++ b/frontend/src/agents/NewAgent.test.tsx @@ -57,6 +57,12 @@ vi.mock('../api/services/devicesService', () => ({ }, })); +vi.mock('../api/services/connectorsService', () => ({ + default: { + listConnections: () => Promise.resolve({ connections: [] }), + }, +})); + vi.mock('../api/services/modelService', () => ({ default: { getModels: () => jsonResponse({ models: [] }), diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index a565e58c..dabd555d 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -45,6 +45,9 @@ import { } from '../components/MultiSelectPopover'; import SourcesPopoverFooter from '../components/SourcesPopoverFooter'; import ToolIcon from '../components/ToolIcon'; +import connectorsService from '../api/services/connectorsService'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import type { Connection } from '../connectors/types'; import AgentDetailsModal from '../modals/AgentDetailsModal'; import ShareToTeamModal from '../teams/ShareToTeamModal'; import ConfirmationModal from '../modals/ConfirmationModal'; @@ -552,12 +555,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { useEffect(() => { const getTools = async () => { - const [toolsResponse, devicesResult] = await Promise.all([ - userService.getUserTools(token), - // Tolerate failures here: the picker should still render the - // tool list even if /api/devices returns an error or 401. - devicesService.list(token).catch(() => ({ devices: [] })), - ]); + const [toolsResponse, devicesResult, connectionsResult] = + await Promise.all([ + userService.getUserTools(token), + // Tolerate failures here: the picker should still render the + // tool list even if /api/devices returns an error or 401. + devicesService.list(token).catch(() => ({ devices: [] })), + connectorsService + .listConnections(token) + .catch(() => ({ connections: [] })), + ]); + const connectionsById = new Map( + ((connectionsResult?.connections ?? []) as Connection[]).map((c) => [ + c.id, + c, + ]), + ); if (!toolsResponse.ok) throw new Error('Failed to fetch tools'); const data = await toolsResponse.json(); // Hide workflow-only builtins (e.g. read_document) from the classic @@ -576,19 +589,39 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { !Number.isNaN(seen) && Date.now() - seen < onlineWindowMs; devicesById.set(d.id, { online, last_seen_at: d.last_seen_at }); }); - // Group ordering: builtins -> defaults -> user tools (sorted via the - // MultiSelectPopover first-appearance grouping). + // Group ordering: builtins -> defaults -> one group per connection + // (the service and its account) -> custom tools, via the + // MultiSelectPopover first-appearance grouping. + const connectionOf = (tool: UserToolType) => + tool.connection_id + ? connectionsById.get(tool.connection_id) + : undefined; + const rank = (tool: UserToolType) => + tool.builtin ? 0 : tool.default ? 1 : connectionOf(tool) ? 2 : 3; const groupFor = (tool: UserToolType): string => { if (tool.builtin) return t('agents.form.toolsPopup.groupBuiltin'); if (tool.default) return t('agents.form.toolsPopup.groupDefault'); + const connection = connectionOf(tool); + if (connection) + return t('agents.form.toolsPopup.groupConnection', { + name: connection.name, + account: connection.account_label, + interpolation: { escapeValue: false }, + }); return t('agents.form.toolsPopup.groupCustom'); }; - const tools: MultiSelectPopoverItem[] = visibleTools.map( - (tool: UserToolType) => { + const tools: MultiSelectPopoverItem[] = [...visibleTools] + .sort((a, b) => rank(a) - rank(b)) + .map((tool: UserToolType) => { + const connection = connectionOf(tool); const base: MultiSelectPopoverItem = { id: tool.id, label: getToolDisplayName(tool), - icon: , + icon: connection ? ( + + ) : ( + + ), group: groupFor(tool), }; if (tool.name === 'remote_device') { @@ -607,8 +640,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { ); } return base; - }, - ); + }); const groupOrder = [ t('agents.form.toolsPopup.groupBuiltin'), t('agents.form.toolsPopup.groupDefault'), diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 65a5cb42..26604a31 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -105,6 +105,9 @@ const endpoints = { CONNECTION_TOOL_PERMISSIONS: (id: string, toolId: string) => `/api/connections/${encodeURIComponent(id)}/tools/${encodeURIComponent(toolId)}/permissions`, CONNECTIONS_CLAIM: '/api/connections/claim', + TOOL_CREDENTIAL_MODE: (toolId: string) => + `/api/connections/tools/${encodeURIComponent(toolId)}/credential-mode`, + ADMIN_CONNECTORS: '/api/admin/connectors', GET_CHUNKS: ( docId: string, page: number, diff --git a/frontend/src/api/services/connectorsService.ts b/frontend/src/api/services/connectorsService.ts index 985122bc..ebe7c7bd 100644 --- a/frontend/src/api/services/connectorsService.ts +++ b/frontend/src/api/services/connectorsService.ts @@ -107,6 +107,22 @@ const connectorsService = { token, ), ), + setCredentialMode: async ( + toolId: string, + mode: 'owner' | 'member', + token: string | null, + ) => + json( + await apiClient.put( + endpoints.USER.TOOL_CREDENTIAL_MODE(toolId), + { mode }, + token, + ), + ), + getAdmin: async (token: string | null) => + json(await apiClient.get(endpoints.USER.ADMIN_CONNECTORS, token)), + updateAdmin: async (body: Record, token: string | null) => + json(await apiClient.put(endpoints.USER.ADMIN_CONNECTORS, body, token)), claim: async (provider: string, sessionToken: string, token: string | null) => json( await apiClient.post( diff --git a/frontend/src/components/MessageInput.test.tsx b/frontend/src/components/MessageInput.test.tsx index ff658ed7..ef3d832a 100644 --- a/frontend/src/components/MessageInput.test.tsx +++ b/frontend/src/components/MessageInput.test.tsx @@ -9,6 +9,7 @@ vi.mock('react-i18next', () => ({ // The upload modal pulls in the whole ingest UI; the composer never opens it here. vi.mock('../upload/Upload', () => ({ default: () => null })); +import connectorsReducer from '../connectors/connectorsSlice'; import notificationsReducer from '../notifications/notificationsSlice'; import { prefSlice } from '../preferences/preferenceSlice'; import type { RootState } from '../store'; @@ -28,6 +29,7 @@ const makeStore = () => preference: prefSlice.reducer, upload: uploadReducer, notifications: notificationsReducer, + connectors: connectorsReducer, }, }); diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 1fd733df..a7c25d5d 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -35,13 +35,18 @@ import { selectToken, setSelectedDocs, } from '../preferences/preferenceSlice'; -import type { RootState } from '../store'; +import type { AppDispatch, RootState } from '../store'; import Upload from '../upload/Upload'; import { isTouchDevice } from '../utils/browserUtils'; import { Button } from './ui/button'; import { IconButton } from './ui/icon-button'; import { type MultiSelectPopoverItem } from './MultiSelectPopover'; import ToolIcon from './ToolIcon'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { + loadConnectors, + selectConnections, +} from '../connectors/connectorsSlice'; import { AttachFileButton, AttachmentChipList, @@ -338,6 +343,7 @@ export default function MessageInput({ const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false); const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false); const [userTools, setUserTools] = useState([]); + const connections = useSelector(selectConnections); const [toolsLoading, setToolsLoading] = useState(false); const [uploadModalState, setUploadModalState] = useState('INACTIVE'); @@ -1561,14 +1567,39 @@ export default function MessageInput({ }, [token]); useEffect(() => { - if (isToolsPopupOpen) fetchUserTools(); + if (isToolsPopupOpen) { + fetchUserTools(); + (dispatch as AppDispatch)(loadConnectors({ token })); + } }, [isToolsPopupOpen, fetchUserTools]); - const toolItems: MultiSelectPopoverItem[] = userTools.map((tool) => ({ - id: tool.id, - label: tool.customName || tool.displayName, - icon: , - })); + // Tools from a connected service sit under that service; the rest are + // built in. Groups only appear once some tool comes from a connection. + const toolConnection = (tool: UserToolType) => + tool.connection_id + ? connections.find((c) => c.id === tool.connection_id) + : undefined; + const anyConnectedTool = userTools.some((tool) => toolConnection(tool)); + const toolItems: MultiSelectPopoverItem[] = [...userTools] + .sort( + (a, b) => + Number(Boolean(toolConnection(a))) - Number(Boolean(toolConnection(b))), + ) + .map((tool) => { + const connection = toolConnection(tool); + return { + id: tool.id, + label: tool.customName || tool.displayName, + icon: connection ? ( + + ) : ( + + ), + group: anyConnectedTool + ? (connection?.name ?? t('settings.tools.groupBuiltIn')) + : undefined, + }; + }); const selectedToolIds = userTools .filter((tool) => tool.status) diff --git a/frontend/src/components/SourcesPopoverFooter.test.tsx b/frontend/src/components/SourcesPopoverFooter.test.tsx index 8e5f6aae..4ed7e827 100644 --- a/frontend/src/components/SourcesPopoverFooter.test.tsx +++ b/frontend/src/components/SourcesPopoverFooter.test.tsx @@ -53,8 +53,11 @@ describe('SourcesPopoverFooter', () => { it('renders the sources link as an inline link Button', async () => { await renderFooter(); - const link = container.querySelector('a')!; + const [link, connect] = Array.from(container.querySelectorAll('a')); expect(link.getAttribute('href')).toBe('/settings/sources'); + expect(connect.getAttribute('href')).toBe( + '/settings/connectors?capability=sync', + ); expect(link.dataset.slot).toBe('button'); expect(link.className).toContain('text-primary'); expect(link.className).toContain('text-sm'); diff --git a/frontend/src/components/SourcesPopoverFooter.tsx b/frontend/src/components/SourcesPopoverFooter.tsx index 0c7a3dcb..27378bad 100644 --- a/frontend/src/components/SourcesPopoverFooter.tsx +++ b/frontend/src/components/SourcesPopoverFooter.tsx @@ -10,7 +10,10 @@ type SourcesPopoverFooterProps = { onUploadClick: () => void; }; -/** Shared footer for source pickers: a link to the sources page and an upload shortcut. */ +/** + * Shared footer for source pickers: links to the sources page and to the + * connectors that can sync content, and an upload shortcut. + */ export default function SourcesPopoverFooter({ onNavigate, onUploadClick, @@ -21,12 +24,20 @@ export default function SourcesPopoverFooter({ // One row when it fits (link left, upload right); on a narrow sheet or a // long locale the button wraps under the link.
- +
+ + +
+ } trigger={ + ) : source.connector_name ? ( +
+ +

+ {t('conversation.sources.fromConnectorTitle', { + name: source.connector_name, + title: source.title, + interpolation: { escapeValue: false }, + })} +

+
) : (
@@ -665,7 +690,13 @@ function onActivateKey( } type AllSourcesProps = { - sources: { title: string; text: string; link?: string }[]; + sources: { + title: string; + text: string; + link?: string; + connector_key?: string | null; + connector_name?: string | null; + }[]; }; function AllSources(sources: AllSourcesProps) { @@ -718,6 +749,20 @@ function AllSources(sources: AllSourcesProps) { )}

+ {source.connector_name && ( +

+ + + {t('conversation.sources.fromConnector', { + name: source.connector_name, + interpolation: { escapeValue: false }, + })} + +

+ )}

{source.text}

diff --git a/frontend/src/conversation/conversationModels.ts b/frontend/src/conversation/conversationModels.ts index e8ba8713..fb0f2360 100644 --- a/frontend/src/conversation/conversationModels.ts +++ b/frontend/src/conversation/conversationModels.ts @@ -61,7 +61,14 @@ export interface Query { conversationId?: string | null; title?: string | null; thought?: string; - sources?: { title: string; text: string; link: string }[]; + sources?: { + title: string; + text: string; + link: string; + // A chunk synced from a connection names the service, never the account. + connector_key?: string | null; + connector_name?: string | null; + }[]; tool_calls?: ToolCallsType[]; // Arrival-ordered layout of the fields above, so reasoning and tool calls // render where they happened. Live-stream only; absent on reload, where diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 501d5ac0..e18e9030 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1066,7 +1066,13 @@ "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." + "custom_openapi": "Eine OpenAPI-Spezifikation importieren und ihre Endpunkte als Werkzeuge aufrufen.", + "mcp_notion": "Notion-Seiten und -Datenbanken durchsuchen, lesen und aktualisieren.", + "mcp_linear": "Linear-Issues und -Projekte finden, anlegen und aktualisieren.", + "mcp_atlassian": "Jira-Issues und Confluence-Seiten durchsuchen und aktualisieren.", + "mcp_sentry": "Sentry-Issues, -Events und -Releases nachschlagen.", + "mcp_asana": "Asana-Aufgaben und -Projekte finden und aktualisieren.", + "mcp_stripe": "Kunden, Zahlungen und Abonnements in Stripe nachschlagen." }, "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.", @@ -1170,6 +1176,18 @@ "error": { "decrypt": "Die gespeicherten Zugangsdaten konnten nicht entschlüsselt werden. Verbinde neu, um fortzufahren.", "defaultKey": "Ein Admin muss ENCRYPTION_SECRET_KEY setzen, bevor Dienste verbunden werden können." + }, + "sharing": { + "owner": "Teammitglieder nutzen mein Konto", + "member": "Jedes Mitglied verbindet sein eigenes Konto" + }, + "share": { + "heading": "Welches Konto Teammitglieder nutzen", + "ownerWarning": "Teammitglieder handeln in {{name}} als {{account}}.", + "confirmWrite": "Ich verstehe, dass Teammitglieder mit meinem Konto Aktionen ausführen können.", + "memberNote": "Jedes Mitglied verbindet sein eigenes {{name}}-Konto, bevor es dieses Werkzeug nutzen kann.", + "forced": "Ein Admin hat das für alle Freigaben dieses Konnektors festgelegt.", + "saveFailed": "Die Änderung konnte nicht gespeichert werden. Versuche es erneut." } } }, @@ -1541,7 +1559,10 @@ "noSourcesAvailable": "Keine Quellen verfügbar", "selectedCount": "{{count}} Quellen ausgewählt", "groupOwn": "Deine Quellen", - "groupTeam": "Mit dem Team geteilt" + "groupTeam": "Mit dem Team geteilt", + "connectMore": "Weitere verbinden", + "fromConnector": "Aus {{name}}", + "fromConnectorTitle": "Aus {{name}}: {{title}}" }, "attachments": { "attach": "Anhängen", @@ -1582,7 +1603,9 @@ "accessingMemory": "Greife auf das Gedächtnis zu…", "runningCode": "Führe Code aus…", "creatingArtifact": "Erstelle ein Artefakt…", - "usingTool": "Verwende {{tool}}…" + "usingTool": "Verwende {{tool}}…", + "searchingConnector": "Durchsuche {{name}}...", + "usingConnector": "Nutze {{name}}..." }, "toolChip": { "searchingWeb": "Im Web gesucht · „{{query}}“", @@ -1593,7 +1616,10 @@ "accessingMemory": "Auf Gedächtnis zugegriffen", "runningCode": "Code ausgeführt", "creatingArtifact": "Artefakt erstellt", - "usingTool": "{{tool}} verwendet" + "usingTool": "{{tool}} verwendet", + "searchedConnector": "{{name}} durchsucht", + "readConnector": "Aus {{name}} gelesen", + "usedConnector": "{{name}} genutzt: {{action}}" }, "inlineSteps": { "arguments": "Argumente", @@ -1783,7 +1809,8 @@ "noOptionsMessage": "Keine Werkzeuge verfügbar", "groupBuiltin": "Integriert", "groupDefault": "Standard", - "groupCustom": "Benutzerdefiniert" + "groupCustom": "Benutzerdefiniert", + "groupConnection": "{{name}} · {{account}}" }, "modelsPopup": { "title": "Modelle auswählen", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 66f638a7..36e5a1ee 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1072,7 +1072,13 @@ "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." + "custom_openapi": "Import an OpenAPI spec and call its endpoints as tools.", + "mcp_notion": "Search, read and update Notion pages and databases.", + "mcp_linear": "Find, create and update Linear issues and projects.", + "mcp_atlassian": "Search and update Jira issues and Confluence pages.", + "mcp_sentry": "Look up Sentry issues, events and releases.", + "mcp_asana": "Find and update Asana tasks and projects.", + "mcp_stripe": "Look up customers, payments and subscriptions in Stripe." }, "empty": "Nothing connected yet. Connect a service to sync its content or give agents new tools.", "noMatches": "No connectors match your search.", @@ -1176,6 +1182,18 @@ "error": { "decrypt": "Stored credentials could not be decrypted. Reconnect to continue.", "defaultKey": "An admin must set ENCRYPTION_SECRET_KEY before services can be connected." + }, + "sharing": { + "owner": "Team members use my account", + "member": "Each member connects their own account" + }, + "share": { + "heading": "Whose account team members use", + "ownerWarning": "Team members will act as {{account}} on {{name}}.", + "confirmWrite": "I understand teammates can take actions with my account.", + "memberNote": "Each member connects their own {{name}} account before they can use this tool.", + "forced": "An admin chose this for every share of this connector.", + "saveFailed": "Could not save the change. Try again." } } }, @@ -1547,7 +1565,10 @@ "noSourcesAvailable": "No sources available", "selectedCount": "{{count}} sources selected", "groupOwn": "Your sources", - "groupTeam": "Shared with team" + "groupTeam": "Shared with team", + "connectMore": "Connect more", + "fromConnector": "From {{name}}", + "fromConnectorTitle": "From {{name}}: {{title}}" }, "attachments": { "attach": "Attach", @@ -1593,7 +1614,9 @@ "accessingMemory": "Accessing memory…", "runningCode": "Running code…", "creatingArtifact": "Creating an artifact…", - "usingTool": "Using {{tool}}…" + "usingTool": "Using {{tool}}…", + "searchingConnector": "Searching {{name}}...", + "usingConnector": "Using {{name}}..." }, "toolChip": { "searchingWeb": "Searched the web · “{{query}}”", @@ -1604,7 +1627,10 @@ "accessingMemory": "Accessed memory", "runningCode": "Ran code", "creatingArtifact": "Created an artifact", - "usingTool": "Used {{tool}}" + "usingTool": "Used {{tool}}", + "searchedConnector": "Searched {{name}}", + "readConnector": "Read from {{name}}", + "usedConnector": "Used {{name}}: {{action}}" }, "inlineSteps": { "arguments": "Arguments", @@ -1799,7 +1825,8 @@ "noOptionsMessage": "No tools available", "groupBuiltin": "Built-in", "groupDefault": "Default", - "groupCustom": "Custom" + "groupCustom": "Custom", + "groupConnection": "{{name}} · {{account}}" }, "modelsPopup": { "title": "Select Models", @@ -2444,7 +2471,8 @@ "admins": "Admins", "usage": "Usage", "quotas": "Quotas", - "audit": "Activity" + "audit": "Activity", + "connectors": "Connectors" }, "quotas": { "unpriced": { @@ -2452,6 +2480,9 @@ "body": "These were used this {{period}} and recorded at $0: {{models}}. Use a token limit for them, or declare their rates in the model catalog.", "model": "{{model}} ({{tokens}} tokens)" } + }, + "connectors": { + "defaultKey": "Set ENCRYPTION_SECRET_KEY before connecting services." } } } diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index d1c250ea..8c76df28 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1066,7 +1066,13 @@ "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." + "custom_openapi": "Importa una especificación OpenAPI y usa sus endpoints como herramientas.", + "mcp_notion": "Busca, lee y actualiza páginas y bases de datos de Notion.", + "mcp_linear": "Encuentra, crea y actualiza incidencias y proyectos de Linear.", + "mcp_atlassian": "Busca y actualiza incidencias de Jira y páginas de Confluence.", + "mcp_sentry": "Consulta incidencias, eventos y versiones de Sentry.", + "mcp_asana": "Encuentra y actualiza tareas y proyectos de Asana.", + "mcp_stripe": "Consulta clientes, pagos y suscripciones en Stripe." }, "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.", @@ -1170,6 +1176,18 @@ "error": { "decrypt": "No se pudieron descifrar las credenciales guardadas. Reconecta para continuar.", "defaultKey": "Un administrador debe definir ENCRYPTION_SECRET_KEY antes de poder conectar servicios." + }, + "sharing": { + "owner": "Los miembros usan mi cuenta", + "member": "Cada miembro conecta su propia cuenta" + }, + "share": { + "heading": "Qué cuenta usan los miembros del equipo", + "ownerWarning": "Los miembros del equipo actuarán como {{account}} en {{name}}.", + "confirmWrite": "Entiendo que los miembros del equipo pueden realizar acciones con mi cuenta.", + "memberNote": "Cada miembro conecta su propia cuenta de {{name}} antes de poder usar esta herramienta.", + "forced": "Un administrador eligió esto para todos los recursos compartidos de este conector.", + "saveFailed": "No se pudo guardar el cambio. Inténtalo de nuevo." } } }, @@ -1541,7 +1559,10 @@ "noSourcesAvailable": "No hay fuentes disponibles", "selectedCount": "{{count}} fuentes seleccionadas", "groupOwn": "Tus fuentes", - "groupTeam": "Compartidas con el equipo" + "groupTeam": "Compartidas con el equipo", + "connectMore": "Conectar más", + "fromConnector": "De {{name}}", + "fromConnectorTitle": "De {{name}}: {{title}}" }, "attachments": { "attach": "Adjuntar", @@ -1582,7 +1603,9 @@ "accessingMemory": "Accediendo a la memoria…", "runningCode": "Ejecutando código…", "creatingArtifact": "Creando un artefacto…", - "usingTool": "Usando {{tool}}…" + "usingTool": "Usando {{tool}}…", + "searchingConnector": "Buscando en {{name}}...", + "usingConnector": "Usando {{name}}..." }, "toolChip": { "searchingWeb": "Buscado en la web · “{{query}}”", @@ -1593,7 +1616,10 @@ "accessingMemory": "Memoria consultada", "runningCode": "Código ejecutado", "creatingArtifact": "Artefacto creado", - "usingTool": "Usado {{tool}}" + "usingTool": "Usado {{tool}}", + "searchedConnector": "Buscó en {{name}}", + "readConnector": "Leyó de {{name}}", + "usedConnector": "Usó {{name}}: {{action}}" }, "inlineSteps": { "arguments": "Argumentos", @@ -1783,7 +1809,8 @@ "noOptionsMessage": "No hay herramientas disponibles", "groupBuiltin": "Integradas", "groupDefault": "Predeterminadas", - "groupCustom": "Personalizadas" + "groupCustom": "Personalizadas", + "groupConnection": "{{name}} · {{account}}" }, "modelsPopup": { "title": "Seleccionar Modelos", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 9b408cda..0f1eb4cb 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1065,7 +1065,13 @@ "ntfy": "ntfy サーバー経由でプッシュ通知を送信します。", "postgres": "Postgres データベースのスキーマを読み取り、SQL を実行します。", "custom_mcp": "任意のリモート Model Context Protocol サーバーに接続します。", - "custom_openapi": "OpenAPI 仕様をインポートし、そのエンドポイントをツールとして呼び出します。" + "custom_openapi": "OpenAPI 仕様をインポートし、そのエンドポイントをツールとして呼び出します。", + "mcp_notion": "Notion のページやデータベースを検索、閲覧、更新します。", + "mcp_linear": "Linear の課題やプロジェクトを検索、作成、更新します。", + "mcp_atlassian": "Jira の課題と Confluence のページを検索、更新します。", + "mcp_sentry": "Sentry の課題、イベント、リリースを調べます。", + "mcp_asana": "Asana のタスクやプロジェクトを検索、更新します。", + "mcp_stripe": "Stripe の顧客、支払い、サブスクリプションを調べます。" }, "empty": "まだ何も接続されていません。サービスを接続すると、コンテンツを同期したり、エージェントに新しいツールを提供したりできます。", "noMatches": "検索に一致するコネクタはありません。", @@ -1164,6 +1170,18 @@ "error": { "decrypt": "保存された認証情報を復号できませんでした。続行するには再接続してください。", "defaultKey": "サービスを接続する前に、管理者が ENCRYPTION_SECRET_KEY を設定する必要があります。" + }, + "sharing": { + "owner": "メンバーは自分のアカウントを使う", + "member": "各メンバーが自分のアカウントを接続する" + }, + "share": { + "heading": "チームメンバーが使うアカウント", + "ownerWarning": "チームメンバーは {{name}} で {{account}} として操作します。", + "confirmWrite": "チームメンバーが自分のアカウントで操作を行えることを理解しました。", + "memberNote": "各メンバーは、このツールを使う前に自分の {{name}} アカウントを接続します。", + "forced": "このコネクタのすべての共有に対して管理者が設定しています。", + "saveFailed": "変更を保存できませんでした。もう一度お試しください。" } } }, @@ -1533,7 +1551,10 @@ "noSourcesAvailable": "利用可能なソースがありません", "selectedCount": "{{count}} 件のソースを選択中", "groupOwn": "自分のソース", - "groupTeam": "チームと共有" + "groupTeam": "チームと共有", + "connectMore": "さらに接続", + "fromConnector": "{{name}} から", + "fromConnectorTitle": "{{name}} から: {{title}}" }, "attachments": { "attach": "添付", @@ -1574,7 +1595,9 @@ "accessingMemory": "メモリにアクセス中…", "runningCode": "コードを実行中…", "creatingArtifact": "アーティファクトを作成中…", - "usingTool": "{{tool}} を使用中…" + "usingTool": "{{tool}} を使用中…", + "searchingConnector": "{{name}} を検索中...", + "usingConnector": "{{name}} を使用中..." }, "toolChip": { "searchingWeb": "ウェブ検索済み · 「{{query}}」", @@ -1585,7 +1608,10 @@ "accessingMemory": "メモリにアクセス済み", "runningCode": "コード実行済み", "creatingArtifact": "アーティファクト作成済み", - "usingTool": "{{tool}} を使用済み" + "usingTool": "{{tool}} を使用済み", + "searchedConnector": "{{name}} を検索しました", + "readConnector": "{{name}} から読み取りました", + "usedConnector": "{{name}} を使用: {{action}}" }, "inlineSteps": { "arguments": "引数", @@ -1774,7 +1800,8 @@ "noOptionsMessage": "利用可能なツールがありません", "groupBuiltin": "ビルトイン", "groupDefault": "デフォルト", - "groupCustom": "カスタム" + "groupCustom": "カスタム", + "groupConnection": "{{name}} · {{account}}" }, "modelsPopup": { "title": "モデルを選択", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index b18c6471..515a2fc8 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1106,7 +1106,13 @@ "ntfy": "Отправляет push-уведомления через сервер ntfy.", "postgres": "Читает схему и выполняет SQL в базе данных Postgres.", "custom_mcp": "Подключает любой удалённый сервер Model Context Protocol.", - "custom_openapi": "Импортирует спецификацию OpenAPI и вызывает её эндпоинты как инструменты." + "custom_openapi": "Импортирует спецификацию OpenAPI и вызывает её эндпоинты как инструменты.", + "mcp_notion": "Поиск, чтение и обновление страниц и баз данных Notion.", + "mcp_linear": "Поиск, создание и обновление задач и проектов Linear.", + "mcp_atlassian": "Поиск и обновление задач Jira и страниц Confluence.", + "mcp_sentry": "Просмотр проблем, событий и релизов Sentry.", + "mcp_asana": "Поиск и обновление задач и проектов Asana.", + "mcp_stripe": "Просмотр клиентов, платежей и подписок в Stripe." }, "empty": "Пока ничего не подключено. Подключите сервис, чтобы синхронизировать его контент или дать агентам новые инструменты.", "noMatches": "Нет коннекторов, подходящих под запрос.", @@ -1220,6 +1226,18 @@ "error": { "decrypt": "Не удалось расшифровать сохранённые учётные данные. Переподключитесь, чтобы продолжить.", "defaultKey": "Администратор должен задать ENCRYPTION_SECRET_KEY, прежде чем можно будет подключать сервисы." + }, + "sharing": { + "owner": "Участники используют мой аккаунт", + "member": "Каждый участник подключает свой аккаунт" + }, + "share": { + "heading": "Чей аккаунт используют участники команды", + "ownerWarning": "Участники команды будут действовать в {{name}} как {{account}}.", + "confirmWrite": "Я понимаю, что участники команды могут выполнять действия от имени моего аккаунта.", + "memberNote": "Каждый участник подключает свой аккаунт {{name}}, прежде чем пользоваться этим инструментом.", + "forced": "Администратор задал это для всех общих доступов к этому коннектору.", + "saveFailed": "Не удалось сохранить изменение. Попробуйте ещё раз." } } }, @@ -1595,7 +1613,10 @@ "noSourcesAvailable": "Нет доступных источников", "selectedCount": "Выбрано источников: {{count}}", "groupOwn": "Ваши источники", - "groupTeam": "Общие с командой" + "groupTeam": "Общие с командой", + "connectMore": "Подключить ещё", + "fromConnector": "Из {{name}}", + "fromConnectorTitle": "Из {{name}}: {{title}}" }, "attachments": { "attach": "Прикрепить", @@ -1636,7 +1657,9 @@ "accessingMemory": "Обращаюсь к памяти…", "runningCode": "Выполняю код…", "creatingArtifact": "Создаю артефакт…", - "usingTool": "Использую {{tool}}…" + "usingTool": "Использую {{tool}}…", + "searchingConnector": "Поиск в {{name}}...", + "usingConnector": "Использую {{name}}..." }, "toolChip": { "searchingWeb": "Поиск в интернете · «{{query}}»", @@ -1647,7 +1670,10 @@ "accessingMemory": "Обращение к памяти выполнено", "runningCode": "Код выполнен", "creatingArtifact": "Артефакт создан", - "usingTool": "Использован {{tool}}" + "usingTool": "Использован {{tool}}", + "searchedConnector": "Поиск в {{name}}", + "readConnector": "Прочитано из {{name}}", + "usedConnector": "Использован {{name}}: {{action}}" }, "inlineSteps": { "arguments": "Аргументы", @@ -1839,7 +1865,8 @@ "noOptionsMessage": "Нет доступных инструментов", "groupBuiltin": "Встроенные", "groupDefault": "По умолчанию", - "groupCustom": "Пользовательские" + "groupCustom": "Пользовательские", + "groupConnection": "{{name}} · {{account}}" }, "modelsPopup": { "title": "Выберите Модели", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 67ea0cfc..89513c4a 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1065,7 +1065,13 @@ "ntfy": "透過 ntfy 伺服器傳送推播通知。", "postgres": "讀取 Postgres 資料庫的結構並執行 SQL。", "custom_mcp": "連接任何遠端 Model Context Protocol 伺服器。", - "custom_openapi": "匯入 OpenAPI 規格,並將其端點作為工具呼叫。" + "custom_openapi": "匯入 OpenAPI 規格,並將其端點作為工具呼叫。", + "mcp_notion": "搜尋、閱讀和更新 Notion 頁面與資料庫。", + "mcp_linear": "尋找、建立和更新 Linear 議題與專案。", + "mcp_atlassian": "搜尋和更新 Jira 議題與 Confluence 頁面。", + "mcp_sentry": "查看 Sentry 問題、事件和發行版本。", + "mcp_asana": "尋找和更新 Asana 任務與專案。", + "mcp_stripe": "查看 Stripe 中的客戶、付款和訂閱。" }, "empty": "尚未連線任何服務。連線一個服務以同步其內容,或為代理提供新工具。", "noMatches": "沒有符合搜尋的連接器。", @@ -1164,6 +1170,18 @@ "error": { "decrypt": "無法解密已儲存的憑證。請重新連線以繼續。", "defaultKey": "管理員必須先設定 ENCRYPTION_SECRET_KEY,才能連線服務。" + }, + "sharing": { + "owner": "團隊成員使用我的帳號", + "member": "每位成員連線自己的帳號" + }, + "share": { + "heading": "團隊成員使用誰的帳號", + "ownerWarning": "團隊成員將在 {{name}} 中以 {{account}} 的身分操作。", + "confirmWrite": "我了解團隊成員可以使用我的帳號執行操作。", + "memberNote": "每位成員須先連線自己的 {{name}} 帳號,才能使用此工具。", + "forced": "管理員已為此連接器的所有共用設定此項。", + "saveFailed": "無法儲存變更。請再試一次。" } } }, @@ -1533,7 +1551,10 @@ "noSourcesAvailable": "沒有可用的來源", "selectedCount": "已選擇 {{count}} 個來源", "groupOwn": "我的來源", - "groupTeam": "團隊共享" + "groupTeam": "團隊共享", + "connectMore": "連線更多", + "fromConnector": "來自 {{name}}", + "fromConnectorTitle": "來自 {{name}}:{{title}}" }, "attachments": { "attach": "附件", @@ -1574,7 +1595,9 @@ "accessingMemory": "正在存取記憶…", "runningCode": "正在執行程式碼…", "creatingArtifact": "正在建立 Artifact…", - "usingTool": "正在使用 {{tool}}…" + "usingTool": "正在使用 {{tool}}…", + "searchingConnector": "正在搜尋 {{name}}...", + "usingConnector": "正在使用 {{name}}..." }, "toolChip": { "searchingWeb": "已搜尋網路 · 「{{query}}」", @@ -1585,7 +1608,10 @@ "accessingMemory": "已存取記憶", "runningCode": "已執行程式碼", "creatingArtifact": "已建立 Artifact", - "usingTool": "已使用 {{tool}}" + "usingTool": "已使用 {{tool}}", + "searchedConnector": "已搜尋 {{name}}", + "readConnector": "已從 {{name}} 讀取", + "usedConnector": "已使用 {{name}}:{{action}}" }, "inlineSteps": { "arguments": "參數", @@ -1774,7 +1800,8 @@ "noOptionsMessage": "沒有可用的工具", "groupBuiltin": "內建", "groupDefault": "預設", - "groupCustom": "自訂" + "groupCustom": "自訂", + "groupConnection": "{{name}} · {{account}}" }, "modelsPopup": { "title": "選擇模型", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 6fd94305..714c7d8a 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1065,7 +1065,13 @@ "ntfy": "通过 ntfy 服务器发送推送通知。", "postgres": "读取 Postgres 数据库的结构并执行 SQL。", "custom_mcp": "连接任意远程 Model Context Protocol 服务器。", - "custom_openapi": "导入 OpenAPI 规范,并将其端点作为工具调用。" + "custom_openapi": "导入 OpenAPI 规范,并将其端点作为工具调用。", + "mcp_notion": "搜索、阅读和更新 Notion 页面与数据库。", + "mcp_linear": "查找、创建和更新 Linear 问题与项目。", + "mcp_atlassian": "搜索和更新 Jira 问题与 Confluence 页面。", + "mcp_sentry": "查看 Sentry 问题、事件和发布版本。", + "mcp_asana": "查找和更新 Asana 任务与项目。", + "mcp_stripe": "查看 Stripe 中的客户、付款和订阅。" }, "empty": "尚未连接任何服务。连接一个服务以同步其内容,或为智能体提供新工具。", "noMatches": "没有与搜索匹配的连接器。", @@ -1164,6 +1170,18 @@ "error": { "decrypt": "无法解密保存的凭据。请重新连接以继续。", "defaultKey": "管理员必须先设置 ENCRYPTION_SECRET_KEY,才能连接服务。" + }, + "sharing": { + "owner": "团队成员使用我的账号", + "member": "每位成员连接自己的账号" + }, + "share": { + "heading": "团队成员使用谁的账号", + "ownerWarning": "团队成员将在 {{name}} 中以 {{account}} 的身份操作。", + "confirmWrite": "我了解团队成员可以使用我的账号执行操作。", + "memberNote": "每位成员需先连接自己的 {{name}} 账号才能使用此工具。", + "forced": "管理员已为此连接器的所有共享设定了此项。", + "saveFailed": "无法保存更改。请重试。" } } }, @@ -1533,7 +1551,10 @@ "noSourcesAvailable": "没有可用的来源", "selectedCount": "已选择 {{count}} 个来源", "groupOwn": "我的来源", - "groupTeam": "团队共享" + "groupTeam": "团队共享", + "connectMore": "连接更多", + "fromConnector": "来自 {{name}}", + "fromConnectorTitle": "来自 {{name}}:{{title}}" }, "attachments": { "attach": "附件", @@ -1574,7 +1595,9 @@ "accessingMemory": "正在访问记忆…", "runningCode": "正在运行代码…", "creatingArtifact": "正在创建工件…", - "usingTool": "正在使用 {{tool}}…" + "usingTool": "正在使用 {{tool}}…", + "searchingConnector": "正在搜索 {{name}}...", + "usingConnector": "正在使用 {{name}}..." }, "toolChip": { "searchingWeb": "已搜索网络 · “{{query}}”", @@ -1585,7 +1608,10 @@ "accessingMemory": "已访问记忆", "runningCode": "已运行代码", "creatingArtifact": "已创建工件", - "usingTool": "已使用 {{tool}}" + "usingTool": "已使用 {{tool}}", + "searchedConnector": "已搜索 {{name}}", + "readConnector": "已从 {{name}} 读取", + "usedConnector": "已使用 {{name}}:{{action}}" }, "inlineSteps": { "arguments": "参数", @@ -1774,7 +1800,8 @@ "noOptionsMessage": "没有可用的工具", "groupBuiltin": "内置", "groupDefault": "默认", - "groupCustom": "自定义" + "groupCustom": "自定义", + "groupConnection": "{{name}} · {{account}}" }, "modelsPopup": { "title": "选择模型", diff --git a/frontend/src/modals/MCPServerModal.tsx b/frontend/src/modals/MCPServerModal.tsx index 7fd62f6c..d8a38ba8 100644 --- a/frontend/src/modals/MCPServerModal.tsx +++ b/frontend/src/modals/MCPServerModal.tsx @@ -558,7 +558,12 @@ export default function MCPServerModal({ ? t('settings.tools.mcp.reconnectServer', { defaultValue: 'Reconnect Server', }) - : t('settings.tools.mcp.addServer') + : server?.preset + ? t('settings.connectors.wizard.connectTitle', { + name: server.displayName, + interpolation: { escapeValue: false }, + }) + : t('settings.tools.mcp.addServer') } size="lg" mobileVariant="sheet" diff --git a/frontend/src/navigation/sections.ts b/frontend/src/navigation/sections.ts index 6f5c680b..b2eb6e66 100644 --- a/frontend/src/navigation/sections.ts +++ b/frontend/src/navigation/sections.ts @@ -220,6 +220,13 @@ export const ADMIN_SECTION: Section = { labelKey: 'admin.tabs.quotas', icon: Gauge, }, + { + key: 'connectors', + path: '/admin/connectors', + labelKey: 'admin.tabs.connectors', + icon: Plug, + feature: 'connectors', + }, { key: 'audit', path: '/admin/audit', diff --git a/frontend/src/settings/Connectors.tsx b/frontend/src/settings/Connectors.tsx index 8656327b..843bdfbf 100644 --- a/frontend/src/settings/Connectors.tsx +++ b/frontend/src/settings/Connectors.tsx @@ -83,9 +83,20 @@ export default function Connectors() { const custom = catalog.filter((c) => c.publisher === 'custom'); const openConnector = catalog.find((c) => c.key === openKey) ?? null; + // "Connect more" in the composer opens the connectors that can do what the + // picker is for: sync content, or give tools. + const capability = searchParams.get('capability'); + const visible = useMemo(() => { const query = search.trim().toLowerCase(); return catalog + .filter((connector) => + capability === 'sync' + ? connector.capabilities.includes('sync') + : capability === 'tools' + ? connector.capabilities.some((c) => c !== 'sync') + : true, + ) .filter((connector) => { if (filter === 'connected') return isConnected(connector); if (filter !== 'all') return connector.category === filter; @@ -98,7 +109,7 @@ export default function Connectors() { connectorDescription(t, connector).toLowerCase().includes(query), ) .sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]); - }, [catalog, filter, search, t]); + }, [catalog, filter, search, t, capability]); const open = (connector: ConnectorDefinition) => { if (connector.state === 'available' || connector.state === 'custom') { diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 1e7c6731..aea97ce1 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -20,6 +20,7 @@ import ConnectorIcon from '../connectors/ConnectorIcon'; import { loadConnectors, selectConnections, + selectConnectorCatalog, } from '../connectors/connectorsSlice'; import { useLoaderState } from '../hooks'; import AddToolModal from '../modals/AddToolModal'; @@ -28,7 +29,9 @@ 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 ShareToTeamModal, { + type ShareCredentials, +} from '../teams/ShareToTeamModal'; import RemoteDeviceConfig from './RemoteDeviceConfig'; import ToolConfig from './ToolConfig'; import { APIToolType, UserToolType } from './types'; @@ -38,6 +41,7 @@ export default function Tools() { const token = useSelector(selectToken); const dispatch = useDispatch(); const connections = useSelector(selectConnections); + const catalog = useSelector(selectConnectorCatalog); const location = useLocation(); const navigate = useNavigate(); @@ -64,6 +68,30 @@ export default function Tools() { [toolId: string]: string; }>({}); + // A connection-backed tool shares its connection's account or asks each + // member to connect their own; the share dialog shows that choice. + const shareCredentials = ( + tool: UserToolType, + ): ShareCredentials | undefined => { + const connection = tool.connection_id + ? connections.find((c) => c.id === tool.connection_id) + : undefined; + if (!connection) return undefined; + const policy = catalog.find( + (c) => c.key === connection.connector_key, + )?.credential_policy; + return { + toolId: tool.id, + connectorName: connection.name, + account: connection.account_label, + mode: tool.credential_mode === 'member' ? 'member' : 'owner', + forcedMode: policy === 'owner' || policy === 'member' ? policy : null, + hasWrites: (tool.actions ?? []).some( + (action) => action.access === 'write', + ), + }; + }; + const handleDeleteTool = (tool: UserToolType) => { setToolToDelete(tool); setDeleteModalState('ACTIVE'); @@ -478,7 +506,11 @@ export default function Tools() { resourceType="tool" resourceId={toolToShare.id} resourceName={toolToShare.customName || toolToShare.displayName} - onClose={() => setToolToShare(null)} + credentials={shareCredentials(toolToShare)} + onClose={() => { + setToolToShare(null); + getUserTools(); + }} /> )}
diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts index cc325908..4073eb8d 100644 --- a/frontend/src/settings/types/index.ts +++ b/frontend/src/settings/types/index.ts @@ -170,6 +170,8 @@ export type UserToolType = { team_access?: 'viewer' | 'editor' | null; // The connection whose account or credentials the tool runs with. connection_id?: string | null; + // Whether team members use the owner's account or their own. + credential_mode?: 'owner' | 'member'; config: { [key: string]: any; }; @@ -193,6 +195,8 @@ export type UserToolType = { }; active: boolean; require_approval?: boolean; + // Read or write, set on tools that come from a connection. + access?: 'read' | 'write'; }[]; }; diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx new file mode 100644 index 00000000..f62cc7e5 --- /dev/null +++ b/frontend/src/teams/ShareToTeamModal.test.tsx @@ -0,0 +1,137 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.account ? `${key}|${opts.account}` : key, + }), +})); + +vi.mock('../api/services/teamsService', () => ({ + default: { + list: vi.fn().mockResolvedValue({ + teams: [{ id: 't1', name: 'Support', slug: 'support', owner_id: 'me' }], + }), + listResourceShares: vi.fn().mockResolvedValue({ shares: [] }), + listMembers: vi.fn().mockResolvedValue({ members: [] }), + }, +})); + +const setCredentialMode = vi.fn(); +vi.mock('../api/services/connectorsService', () => ({ + default: { + setCredentialMode: (...args: unknown[]) => setCredentialMode(...args), + }, +})); + +import { prefSlice } from '../preferences/preferenceSlice'; +import ShareToTeamModal, { type ShareCredentials } from './ShareToTeamModal'; +import teamsReducer from './teamsSlice'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const credentials = ( + overrides: Partial = {}, +): ShareCredentials => ({ + toolId: 'tool-1', + connectorName: 'Linear', + account: 'lena@meridian.example', + mode: 'owner', + hasWrites: false, + ...overrides, +}); + +describe('ShareToTeamModal credentials', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + setCredentialMode.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const render = async (creds?: ShareCredentials) => { + const store = configureStore({ + reducer: { preference: prefSlice.reducer, teams: teamsReducer }, + }); + await act(async () => { + root.render( + + undefined} + /> + , + ); + }); + }; + + const toggle = (value: 'owner' | 'member') => + Array.from( + document.body.querySelectorAll( + '[data-slot="option-card"]', + ), + ).find((item) => + item.textContent?.includes(`settings.connectors.sharing.${value}`), + )!; + const picker = () => + document.body.querySelector('[role="combobox"]')!; + + it('shows nothing about accounts for a resource without a connection', async () => { + await render(); + expect(document.body.textContent).not.toContain( + 'settings.connectors.share.heading', + ); + }); + + it('warns that members act as the owner in owner mode', async () => { + await render(credentials()); + expect(toggle('owner').getAttribute('aria-checked')).toBe('true'); + expect(document.body.textContent).toContain( + 'settings.connectors.share.ownerWarning|lena@meridian.example', + ); + }); + + it('blocks sharing an owner-mode tool with writes until confirmed', async () => { + await render(credentials({ hasWrites: true })); + expect(picker().disabled).toBe(true); + const box = document.body.querySelector( + '#share-confirm-writes', + )!; + await act(async () => box.click()); + expect(picker().disabled).toBe(false); + }); + + it('saves a mode change and rolls back when the save fails', async () => { + setCredentialMode.mockResolvedValue({ success: false }); + await render(credentials({ hasWrites: true })); + await act(async () => toggle('member').click()); + expect(setCredentialMode).toHaveBeenCalledWith('tool-1', 'member', null); + expect(toggle('owner').getAttribute('aria-checked')).toBe('true'); + expect(document.body.textContent).toContain( + 'settings.connectors.share.saveFailed', + ); + }); + + it('locks the other mode when an admin forces one', async () => { + await render(credentials({ forcedMode: 'member' })); + expect(toggle('member').getAttribute('aria-checked')).toBe('true'); + expect(toggle('owner').disabled).toBe(true); + expect(document.body.textContent).toContain( + 'settings.connectors.share.forced', + ); + }); +}); diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx index 54f4c9d7..da19ff81 100644 --- a/frontend/src/teams/ShareToTeamModal.tsx +++ b/frontend/src/teams/ShareToTeamModal.tsx @@ -1,4 +1,4 @@ -import { CircleAlert, Trash2 } from 'lucide-react'; +import { CircleAlert, Trash2, UserRound, UsersRound } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -9,7 +9,11 @@ import teamsService, { ResourceType, TeamMember, } from '../api/services/teamsService'; +import connectorsService from '../api/services/connectorsService'; import { Alert, AlertDescription } from '../components/ui/alert'; +import { Checkbox } from '../components/ui/checkbox'; +import { Label } from '../components/ui/label'; +import { OptionCard } from '../components/ui/option-card'; import { Avatar } from '../components/ui/avatar'; import { Button } from '../components/ui/button'; import { @@ -41,10 +45,23 @@ import { AppDispatch } from '../store'; import { decodeJwtPayload } from '../utils/jwtUtils'; import { loadTeams, selectTeams } from './teamsSlice'; +/** A connection-backed tool: whose account shares of it run with. */ +export type ShareCredentials = { + toolId: string; + connectorName: string; + account: string; + mode: 'owner' | 'member'; + /** Set when an admin forces one mode for every share of this connector. */ + forcedMode?: 'owner' | 'member' | null; + /** Owner-mode shares of a tool with write actions need an explicit OK. */ + hasWrites: boolean; +}; + type Props = { resourceType: ResourceType; resourceId: string; resourceName?: string; + credentials?: ShareCredentials; onClose: () => void; }; @@ -84,6 +101,7 @@ export default function ShareToTeamModal({ resourceType, resourceId, resourceName, + credentials, onClose, }: Props) { const { t } = useTranslation(); @@ -101,6 +119,27 @@ export default function ShareToTeamModal({ const [loadError, setLoadError] = useState(false); const [actionError, setActionError] = useState(null); + const [credentialMode, setCredentialMode] = useState<'owner' | 'member'>( + credentials?.forcedMode ?? credentials?.mode ?? 'owner', + ); + const [writesConfirmed, setWritesConfirmed] = useState(false); + const needsWriteConfirm = + !!credentials && credentialMode === 'owner' && credentials.hasWrites; + const changeCredentialMode = (mode: 'owner' | 'member') => { + if (!credentials || mode === credentialMode) return; + const previous = credentialMode; + setCredentialMode(mode); + connectorsService + .setCredentialMode(credentials.toolId, mode, token) + .then((data) => { + if (!data?.success) throw new Error('save failed'); + }) + .catch(() => { + setCredentialMode(previous); + setActionError(t('settings.connectors.share.saveFailed')); + }); + }; + // The access level applied to the next suggestion picked from the combobox. const [accessLevel, setAccessLevel] = useState('viewer'); @@ -508,6 +547,76 @@ export default function ShareToTeamModal({

{t('settings.teams.share.noTeams')}

) : ( <> + {credentials && ( +
+ +
+ {(['owner', 'member'] as const).map((mode) => ( + : } + title={t(`settings.connectors.sharing.${mode}`)} + selected={credentialMode === mode} + disabled={ + !!credentials.forcedMode && + credentials.forcedMode !== mode + } + onClick={() => changeCredentialMode(mode)} + /> + ))} +
+ {credentials.forcedMode && ( +

+ {t('settings.connectors.share.forced')} +

+ )} + {credentialMode === 'owner' ? ( + + + ) : ( +

+ {t('settings.connectors.share.memberNote', { + name: credentials.connectorName, + interpolation: { escapeValue: false }, + })} +

+ )} + {needsWriteConfirm && ( +
+ + setWritesConfirmed(checked === true) + } + /> + +
+ )} +
+ )} + {/* Add row: type-ahead combobox + access level select. */}
@@ -517,7 +626,9 @@ export default function ShareToTeamModal({ variant="combobox" role="combobox" aria-expanded={pickerOpen} - disabled={committing} + disabled={ + committing || (needsWriteConfirm && !writesConfirmed) + } data-placeholder="" className="min-w-0 flex-1 justify-start" > diff --git a/frontend/src/utils/streamingStatusUtils.test.ts b/frontend/src/utils/streamingStatusUtils.test.ts index 1b30823c..5973bdc2 100644 --- a/frontend/src/utils/streamingStatusUtils.test.ts +++ b/frontend/src/utils/streamingStatusUtils.test.ts @@ -125,4 +125,64 @@ describe('getToolChipLabel', () => { ), ).toBe('conversation.toolChip.usingTool|Mcp Tool'); }); + it('names a connection-backed call after its service', () => { + const notion = { tool_name: 'mcp_tool', connector_name: 'Notion' }; + expect( + getToolChipLabel( + call({ ...notion, action_name: 'notion-search', access: 'read' }), + t, + ), + ).toBe('conversation.toolChip.searchedConnector|Notion'); + expect( + getToolChipLabel( + call({ + ...notion, + action_name: 'notion-fetch', + access: 'read', + status: 'pending', + }), + t, + ), + ).toBe('conversation.streamingStatus.usingConnector|Notion'); + expect( + getToolChipLabel( + call({ ...notion, action_name: 'notion-fetch', access: 'read' }), + t, + ), + ).toBe('conversation.toolChip.readConnector|Notion'); + }); + + it('names the action of a write without the service prefix', () => { + expect( + getToolChipLabel( + call({ + tool_name: 'mcp_tool', + connector_name: 'Linear', + action_name: 'linear_create_issue', + access: 'write', + }), + t, + ), + ).toBe('conversation.toolChip.usedConnector|Linear,create issue'); + expect( + getToolChipLabel( + call({ + tool_name: 'telegram', + connector_name: 'Telegram', + action_name: 'send_message', + access: 'write', + }), + t, + ), + ).toBe('conversation.toolChip.usedConnector|Telegram,send message'); + }); + + it('keeps built-in labels for web search even with a connector name', () => { + expect( + getToolChipLabel( + call({ connector_name: 'Brave', arguments: { query: 'docsgpt' } }), + t, + ), + ).toBe('conversation.toolChip.searchingWeb|docsgpt'); + }); }); diff --git a/frontend/src/utils/streamingStatusUtils.ts b/frontend/src/utils/streamingStatusUtils.ts index f1925447..dc0a466d 100644 --- a/frontend/src/utils/streamingStatusUtils.ts +++ b/frontend/src/utils/streamingStatusUtils.ts @@ -41,6 +41,53 @@ const NO_ESCAPE = { interpolation: { escapeValue: false } } as const; */ export type ToolActivity = { key: string; values?: Record }; +const CONNECTOR_SEARCH_WORDS = /search|query|find|list/i; + +/** + * A call to a connection-backed tool, named after its service: "Searched + * Notion", "Read from Google Drive", "Used Linear: create issue". Reads that + * look up something search; other reads read; writes name their action. + */ +function describeConnectorCall(toolCall: ToolCallsType): ToolActivity | null { + const name = toolCall.connector_name; + if (!name) return null; + const action = toolCall.action_name ?? ''; + if (toolCall.access === 'write') { + const readable = action + .replace(/^[a-z0-9]+[_-](?=[a-z])/i, (prefix) => + // "linear_create_issue" and "notion-create-pages" drop the service + // prefix; a bare verb ("search") keeps its whole name. + name.toLowerCase().startsWith(prefix.slice(0, -1).toLowerCase()) + ? '' + : prefix, + ) + .replace(/[_-]+/g, ' ') + .trim() + .toLowerCase(); + return { key: 'connectorWrite', values: { name, action: readable } }; + } + if (CONNECTOR_SEARCH_WORDS.test(action)) + return { key: 'connectorSearch', values: { name } }; + return { key: 'connectorRead', values: { name } }; +} + +// Connector activities read differently while running and once done. +const CONNECTOR_KEYS: Record< + 'streamingStatus' | 'toolChip', + Record +> = { + streamingStatus: { + connectorSearch: 'searchingConnector', + connectorRead: 'usingConnector', + connectorWrite: 'usingConnector', + }, + toolChip: { + connectorSearch: 'searchedConnector', + connectorRead: 'readConnector', + connectorWrite: 'usedConnector', + }, +}; + export function describeToolCall(toolCall: ToolCallsType): ToolActivity { const { tool_name, action_name, arguments: args } = toolCall; const query = typeof args?.query === 'string' ? args.query : undefined; @@ -63,6 +110,8 @@ export function describeToolCall(toolCall: ToolCallsType): ToolActivity { return { key: 'readingPage', values: { target } }; } } + const connector = describeConnectorCall(toolCall); + if (connector) return connector; if (action_name === 'run_code') return { key: 'runningCode' }; if (ARTIFACT_ACTIONS.has(action_name)) return { key: 'creatingArtifact' }; if (tool_name === 'internal_search') return { key: 'searchingKnowledge' }; @@ -84,7 +133,9 @@ function activityLabel( t: TFunction, ): string { const key = - activity.key === 'web' ? GENERIC_SEARCH_KEY[namespace] : activity.key; + activity.key === 'web' + ? GENERIC_SEARCH_KEY[namespace] + : (CONNECTOR_KEYS[namespace][activity.key] ?? activity.key); return t( `conversation.${namespace}.${key}`, activity.values ? { ...activity.values, ...NO_ESCAPE } : undefined,