diff --git a/frontend/src/api/services/connectorsService.ts b/frontend/src/api/services/connectorsService.ts index f8c3d1f4..d310e563 100644 --- a/frontend/src/api/services/connectorsService.ts +++ b/frontend/src/api/services/connectorsService.ts @@ -1,3 +1,4 @@ +import type { SourceConfig } from '../../models/misc'; import apiClient, { throttledApiClient } from '../client'; import endpoints from '../endpoints'; @@ -20,6 +21,8 @@ export type ConnectionSetupBody = { items: Record; frequency?: string; name?: string; + /** The source's retrieval settings, as an upload sends them. */ + config?: SourceConfig; }; }; diff --git a/frontend/src/connectors/ConnectWizard.test.tsx b/frontend/src/connectors/ConnectWizard.test.tsx index bee1ec3e..e6046687 100644 --- a/frontend/src/connectors/ConnectWizard.test.tsx +++ b/frontend/src/connectors/ConnectWizard.test.tsx @@ -27,6 +27,7 @@ vi.mock('../api/services/connectorsService', () => ({ default: service })); const mcpApi = vi.hoisted(() => ({ testMCPConnection: vi.fn(), saveMCPServer: vi.fn(), + getConfig: vi.fn(), })); vi.mock('../api/services/userService', async (importOriginal) => { const original = await importOriginal<{ default: object }>(); @@ -102,6 +103,10 @@ vi.mock('./LinearPicker', async (importOriginal) => { import notificationsReducer, { sseEventReceived, } from '../notifications/notificationsSlice'; +import { + DEFAULT_RETRIEVAL_OPTIONS, + optionsToConfig, +} from '../settings/components/RetrievalOptions'; import connectorsReducer from './connectorsSlice'; import ConnectWizard from './ConnectWizard'; import type { ConnectorDefinition } from './types'; @@ -206,6 +211,7 @@ describe('ConnectWizard', () => { success: true, connections: [], }); + mcpApi.getConfig.mockResolvedValue({ json: async () => ({}) }); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -261,6 +267,9 @@ describe('ConnectWizard', () => { await act(async () => button!.click()); }; + const knowledgeSwitch = () => + document.body.querySelector('[id^="knowledge-"]'); + const typeInto = async (input: HTMLInputElement, value: string) => { const setter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, @@ -377,7 +386,7 @@ describe('ConnectWizard', () => { }); it('lets a content service skip choosing what to sync', async () => { - await render(drive); + await render(drive, vi.fn(), { purpose: 'knowledge' }); await click('settings.connectors.wizard.signIn'); expect(document.body.textContent).toContain( 'settings.connectors.wizard.chooseWhatToSync', @@ -395,7 +404,7 @@ describe('ConnectWizard', () => { tools: [], sources: [{ id: 'src-1', name: 'Handbook' }], }); - await render(drive); + await render(drive, vi.fn(), { purpose: 'knowledge' }); await click('settings.connectors.wizard.signIn'); await click('pick-folder'); await click('modals.uploadDoc.train'); @@ -405,6 +414,7 @@ describe('ConnectWizard', () => { items: { file_ids: [], folder_ids: ['folder-1'] }, frequency: 'weekly', name: 'Handbook', + config: optionsToConfig(DEFAULT_RETRIEVAL_OPTIONS), }); expect(typeof key).toBe('string'); expect(document.body.textContent).toContain( @@ -412,6 +422,159 @@ describe('ConnectWizard', () => { ); }); + describe('Sync into Knowledge', () => { + const button = (text: string) => + Array.from( + document.body.querySelectorAll('button'), + ).find((b) => b.textContent?.trim() === text); + + it('is off on a plain connect: the account is made, nothing syncs', async () => { + await render(drive); + await click('settings.connectors.wizard.signIn'); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.chooseWhatToSetUp', + ); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.syncToKnowledge', + ); + expect(knowledgeSwitch()!.getAttribute('aria-checked')).toBe('false'); + // The picker, name, frequency and retrieval settings stay folded away. + expect(document.body.textContent).not.toContain('pick-folder'); + expect(document.body.textContent).not.toContain( + 'settings.connectors.wizard.syncFrequency', + ); + expect(document.body.textContent).not.toContain( + 'settings.connectors.wizard.retrievalSettings', + ); + // One way on: nothing to skip. + expect(button('settings.connectors.wizard.skip')).toBeUndefined(); + await click('settings.connectors.wizard.continue'); + expect(service.setup).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.doneTitle', + ); + // The summary says nothing syncs yet, and where to start later. + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.syncLater', + ); + }); + + it('shows the picker once switched on', async () => { + service.setup.mockResolvedValue({ + success: true, + tools: [], + sources: [{ id: 'src-1', name: 'Handbook' }], + }); + await render(drive); + await click('settings.connectors.wizard.signIn'); + await act(async () => knowledgeSwitch()!.click()); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.chooseWhatToSync', + ); + expect(button('modals.uploadDoc.train')?.disabled).toBe(true); + await click('pick-folder'); + await click('modals.uploadDoc.train'); + expect(service.setup.mock.calls[0][1].sync.items).toEqual({ + file_ids: [], + folder_ids: ['folder-1'], + }); + expect(document.body.textContent).not.toContain( + 'settings.connectors.wizard.syncLater', + ); + }); + + it('drops the picked items when switched back off', async () => { + await render(drive, vi.fn(), { purpose: 'knowledge' }); + await click('settings.connectors.wizard.signIn'); + expect(knowledgeSwitch()!.getAttribute('aria-checked')).toBe('true'); + await click('pick-folder'); + await act(async () => knowledgeSwitch()!.click()); + await act(async () => knowledgeSwitch()!.click()); + // The picker starts over, so nothing is left to add. + expect(button('modals.uploadDoc.train')?.disabled).toBe(true); + }); + + it('sends the advanced retrieval settings with the sync', async () => { + service.setup.mockResolvedValue({ + success: true, + tools: [], + sources: [], + }); + await render(drive, vi.fn(), { purpose: 'knowledge' }); + await click('settings.connectors.wizard.signIn'); + const toggle = button('settings.connectors.wizard.retrievalSettings')!; + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + await typeInto( + document.body.querySelector('#retrieval-chunks')!, + '9', + ); + await click('pick-folder'); + await click('modals.uploadDoc.train'); + expect(service.setup.mock.calls[0][1].sync.config).toEqual( + optionsToConfig({ + ...DEFAULT_RETRIEVAL_OPTIONS, + retrieval: { ...DEFAULT_RETRIEVAL_OPTIONS.retrieval, chunks: 9 }, + }), + ); + }); + + it('blocks the sync while the prescreen settings do not add up', async () => { + await render(drive, vi.fn(), { purpose: 'knowledge' }); + await click('settings.connectors.wizard.signIn'); + await click('pick-folder'); + await click('settings.connectors.wizard.retrievalSettings'); + await act(async () => + document.body + .querySelector('#retrieval-prescreen')! + .click(), + ); + // More chunks than prescreen candidates. + await typeInto( + document.body.querySelector('#retrieval-chunks')!, + '50', + ); + expect(button('modals.uploadDoc.train')?.disabled).toBe(true); + }); + + it('is not asked about when syncing more from an account', async () => { + await render(drive, vi.fn(), { + mode: 'sync', + connectionId: 'conn-drive', + }); + expect(knowledgeSwitch()).toBeNull(); + expect(document.body.textContent).toContain('pick-folder'); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.chooseWhatToSync', + ); + }); + + it('is not offered by a service that does not sync', async () => { + service.createConnection.mockResolvedValue({ + success: true, + connection: { id: 'conn-1' }, + }); + service.setup.mockResolvedValue({ + success: true, + tools: [], + sources: [], + }); + await render(base, vi.fn(), { purpose: 'knowledge' }); + await typeInto( + document.body.querySelector( + 'input[type="password"]', + )!, + 't', + ); + await click('settings.connectors.status.connect'); + expect(knowledgeSwitch()).toBeNull(); + expect(document.body.textContent).not.toContain( + 'settings.connectors.wizard.syncLater', + ); + }); + }); + it('opens a new chat from Try it in chat', async () => { service.createConnection.mockResolvedValue({ success: true, @@ -589,7 +752,7 @@ describe('ConnectWizard', () => { tools: [{ ...TELEGRAM_TOOL, name: 'mcp_tool', display_name: 'GitHub' }], sources: [{ id: 'src-1', name: 'octocat/private' }], }); - await render(github); + await render(github, vi.fn(), { purpose: 'knowledge' }); await connectWithToken(); await click('pick-repo'); await click('settings.connectors.wizard.continue'); @@ -601,6 +764,7 @@ describe('ConnectWizard', () => { items: { repo_url: 'octocat/private' }, frequency: 'weekly', name: 'octocat/private', + config: optionsToConfig(DEFAULT_RETRIEVAL_OPTIONS), }, }); expect(document.body.textContent).toContain( @@ -629,7 +793,7 @@ describe('ConnectWizard', () => { tools: [], sources: [], }); - await render(github); + await render(github, vi.fn(), { purpose: 'knowledge' }); await connectWithToken(); const toolSwitch = document.body.querySelector('[role="switch"]')!; @@ -762,7 +926,7 @@ describe('ConnectWizard', () => { tools: [], sources: [{ id: 'src-1', name: 'Linear · Engineering' }], }); - await signIn(); + await signIn({ purpose: 'knowledge' }); // The tools came with the sign-in; syncing is the next question. expect(document.body.textContent).toContain( 'settings.connectors.wizard.chooseWhatToSync', @@ -782,6 +946,7 @@ describe('ConnectWizard', () => { }, frequency: 'weekly', name: 'Linear · Engineering', + config: optionsToConfig(DEFAULT_RETRIEVAL_OPTIONS), }, }); // The summary still lists the tools from the sign-in. @@ -791,7 +956,7 @@ describe('ConnectWizard', () => { }); it('can skip syncing and keep only the tools', async () => { - await signIn(); + await signIn({ purpose: 'knowledge' }); const train = Array.from( document.body.querySelectorAll('button'), ).find((b) => b.textContent?.trim() === 'modals.uploadDoc.train'); @@ -807,6 +972,20 @@ describe('ConnectWizard', () => { ); }); + it('asks before syncing on a plain connect, and keeps the tools', async () => { + await signIn(); + expect(knowledgeSwitch()!.getAttribute('aria-checked')).toBe('false'); + expect(document.body.textContent).not.toContain('pick-team'); + await click('settings.connectors.wizard.continue'); + expect(service.setup).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.toolsHeading:1', + ); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.syncLater', + ); + }); + it('goes straight to the summary after signing in again', async () => { await signIn({ mode: 'reconnect', connectionId: 'conn-lin' }); expect(document.body.textContent).toContain( @@ -820,6 +999,8 @@ describe('ConnectWizard', () => { connectionId: 'conn-lin', }); expect(document.body.textContent).toContain('pick-team'); + // Syncing is why it opened: no question about it. + expect(knowledgeSwitch()).toBeNull(); }); }); }); diff --git a/frontend/src/connectors/ConnectWizard.tsx b/frontend/src/connectors/ConnectWizard.tsx index 992d6139..dffe7a02 100644 --- a/frontend/src/connectors/ConnectWizard.tsx +++ b/frontend/src/connectors/ConnectWizard.tsx @@ -42,6 +42,13 @@ import { setSelectedAgent, setSelectedDocs, } from '../preferences/preferenceSlice'; +import RetrievalOptions, { + DEFAULT_RETRIEVAL_OPTIONS, + isPrescreenConfigValid, + optionsToConfig, + type RetrievalOptionsValue, +} from '../settings/components/RetrievalOptions'; +import useRetrievalAvailability from '../settings/components/useRetrievalAvailability'; import type { AppDispatch } from '../store'; import { formatCount } from '../utils/dateTimeUtils'; import { ACCOUNT_NAME_MAX } from './accounts'; @@ -65,6 +72,12 @@ import type { } from './types'; export type WizardMode = 'connect' | 'reconnect' | 'sync' | 'done'; +/** + * Why the wizard opened. `knowledge` (Add knowledge, Knowledge's Connect a + * service) starts a first connect with Sync into Knowledge on; `tools`, or + * none, leaves it off for the user to choose. + */ +export type LaunchPurpose = 'knowledge' | 'tools'; const FREQUENCIES = ['never', 'daily', 'weekly', 'monthly'] as const; const PICKER_CONNECTORS = new Set([ @@ -80,16 +93,19 @@ const GITHUB_TOKEN_URL = type CreatedSource = { id: string; name: string }; /** - * The one connect flow every entry point opens: sign in, choose what to - * sync (content connectors only, skippable), then a summary with Try it in - * chat. Tool connectors create their tools on sign-in, writes needing - * approval, so they go from the credentials straight to the summary. + * The one connect flow every entry point opens: sign in, choose what to set + * up, then a summary with Try it in chat. A content connector asks whether to + * sync into Knowledge (on when opened for Knowledge) and, if so, what to sync + * and with which retrieval settings. Tool connectors create their tools on + * sign-in, writes needing approval, so they go from the credentials straight + * to the summary. */ export default function ConnectWizard({ connector, mode = 'connect', connectionId: initialConnectionId = null, mcpToolId, + purpose, onClose, onFinished, }: { @@ -98,6 +114,7 @@ export default function ConnectWizard({ connectionId?: string | null; /** Reconnecting an MCP preset: the tool to update rather than add. */ mcpToolId?: string; + purpose?: LaunchPurpose; onClose: () => void; onFinished?: () => void; }) { @@ -144,6 +161,15 @@ export default function ConnectWizard({ const connection = connections.find((c) => c.id === connectionId); const canSync = connector.setup.sync !== 'off' && !!connector.sync_ingestor; + // A first connect asks whether to sync into Knowledge; Sync more is only + // about syncing, so it never asks. + const askSync = canSync && mode === 'connect'; + const [syncOn, setSyncOn] = useState(purpose === 'knowledge'); + const syncing = canSync && (mode === 'sync' || syncOn); + const [retrievalOptions, setRetrievalOptions] = + useState(DEFAULT_RETRIEVAL_OPTIONS); + const { graphRAGAvailable, hybridAvailable, availableModels } = + useRetrievalAvailability(token, step === 'setup' && syncing); // Tools the user opts into while choosing what to sync, not on sign-in. const offerTools = connector.setup.tools === 'ask' && @@ -339,10 +365,31 @@ export default function ConnectWizard({ const wantsTools = offerTools && toolsOn; const offerWrites = wantsTools && !!connector.writes_allowed; const wantsWrites = offerWrites && writesOn; - const canAddSource = hasSyncSelection || wantsTools; + const syncReady = syncing && hasSyncSelection; + // Off, there is nothing to pick: the button only finishes connecting. + // On, something must be picked (or the tools wanted), and an incoherent + // prescreen config blocks it as in Upload; the backend would refuse it. + const canAddSource = + !syncing || + ((hasSyncSelection || wantsTools) && + isPrescreenConfigValid(retrievalOptions)); + + const toggleSync = (on: boolean) => { + setSyncOn(on); + // The file pickers start over when shown again; drop what they reported. + if (!on) { + setSelectedFiles([]); + setSelectedFolders([]); + } + }; const addSource = async () => { if (!connectionId) return; + if (!wantsTools && !syncReady) { + // Connected, nothing more to set up. + setStep('done'); + return; + } setPending(true); setError(''); try { @@ -351,11 +398,12 @@ export default function ConnectWizard({ { create_tools: wantsTools, ...(wantsWrites && { allow_writes: true }), - ...(hasSyncSelection && { + ...(syncReady && { sync: { items: syncItems(), frequency, name: sourceName.trim() || undefined, + config: optionsToConfig(retrievalOptions), }, }), }, @@ -448,8 +496,16 @@ export default function ConnectWizard({ : toolCount ? t('settings.connectors.wizard.doneTools', { tools: toolsText }) : ''; - return [accountLine, countsLine].filter(Boolean).join(' '); - }, [connection, sources, toolCount, t]); + // A new account that syncs nothing yet: say where syncing starts later. + const syncLine = + mode === 'connect' && canSync && sources.length === 0 + ? t('settings.connectors.wizard.syncLater', { + name, + interpolation: { escapeValue: false }, + }) + : ''; + return [accountLine, countsLine, syncLine].filter(Boolean).join(' '); + }, [connection, sources, toolCount, mode, canSync, name, t]); const renderSignIn = () => (
@@ -559,29 +615,31 @@ export default function ConnectWizard({ {error} )} - {offerTools && ( + {(offerTools || askSync) && ( - - setToolsOn(checked === true)} - /> - + {offerTools && ( + + setToolsOn(checked === true)} + /> + + )} {offerWrites && ( )} + {askSync && ( + + toggleSync(checked === true)} + /> + + )} )} - {isRepoPicker && connectionId ? ( - { - setSelectedRepo(fullName); - if (!nameTouched) setSourceName(fullName); - }} - /> - ) : isLinearPicker && connectionId ? ( - { - setLinearSelection(selection); - if (!nameTouched) setSourceName(linearSourceName(selection)); - }} - /> - ) : connector.key === 'google_drive' && - envVar('VITE_GOOGLE_CLIENT_ID') ? ( - { - setSelectedFiles(fileIds); - setSelectedFolders(folderIds); - }} - /> - ) : PICKER_CONNECTORS.has(connector.key) ? ( - { - setSelectedFiles(fileIds); - setSelectedFolders(folderIds); - }} - /> - ) : ( - { - setSetupValues(values); - const first = Object.values(values).find(Boolean); - if (first) prefillName(first); - }} - /> + {syncing && ( + <> + {isRepoPicker && connectionId ? ( + { + setSelectedRepo(fullName); + if (!nameTouched) setSourceName(fullName); + }} + /> + ) : isLinearPicker && connectionId ? ( + { + setLinearSelection(selection); + if (!nameTouched) setSourceName(linearSourceName(selection)); + }} + /> + ) : connector.key === 'google_drive' && + envVar('VITE_GOOGLE_CLIENT_ID') ? ( + { + setSelectedFiles(fileIds); + setSelectedFolders(folderIds); + }} + /> + ) : PICKER_CONNECTORS.has(connector.key) ? ( + { + setSelectedFiles(fileIds); + setSelectedFolders(folderIds); + }} + /> + ) : ( + { + setSetupValues(values); + const first = Object.values(values).find(Boolean); + if (first) prefillName(first); + }} + /> + )} +
+ { + setNameTouched(true); + setSourceName(e.target.value); + }} + /> + + + +
+ + )} -
- { - setNameTouched(true); - setSourceName(e.target.value); - }} - /> - - - -
); @@ -723,7 +810,7 @@ export default function ConnectWizard({ interpolation: { escapeValue: false }, }) : step === 'setup' - ? offerTools + ? offerTools || !syncing ? t('settings.connectors.wizard.chooseWhatToSetUp') : t('settings.connectors.wizard.chooseWhatToSync') : t('settings.connectors.wizard.doneTitle', { @@ -763,6 +850,11 @@ export default function ConnectWizard({ } /> ) + ) : step === 'setup' && !offerTools && !syncing ? ( + // Only the Knowledge question, answered no: Skip would do the same. + ) : step === 'setup' ? ( ({ default: ({ mode, connectionId, + purpose, }: { mode: string; connectionId?: string; - }) =>
{`${mode}:${connectionId ?? ''}`}
, + purpose?: string; + }) => ( +
+ {`${mode}:${connectionId ?? ''}`} +
+ ), })); vi.mock('../modals/MCPServerModal', () => ({ default: () => null })); @@ -32,6 +38,14 @@ const LINEAR = { sync_ingestor: 'linear', } as unknown as ConnectorDefinition; +const DRIVE = { + key: 'google_drive', + name: 'Google Drive', + auth_kind: 'oauth', + mcp_url: null, + sync_ingestor: 'google_drive', +} as unknown as ConnectorDefinition; + let launchRef: ((c: ConnectorDefinition, o?: LaunchOptions) => void) | null = null; @@ -89,4 +103,22 @@ describe('useConnectorLauncher', () => { ); expect(wizard()).toBe('reconnect:c1'); }); + + const purpose = () => + container + .querySelector('[data-testid="wizard"]') + ?.getAttribute('data-purpose'); + + it('tells the wizard it was opened for Knowledge', async () => { + await act(async () => launchRef!(LINEAR, { purpose: 'knowledge' })); + expect(purpose()).toBe('knowledge'); + await act(async () => launchRef!(DRIVE, { purpose: 'knowledge' })); + expect(wizard()).toBe('connect:'); + expect(purpose()).toBe('knowledge'); + }); + + it('leaves a plain connect without a purpose', async () => { + await act(async () => launchRef!(DRIVE)); + expect(purpose()).toBe(''); + }); }); diff --git a/frontend/src/connectors/useConnectorLauncher.tsx b/frontend/src/connectors/useConnectorLauncher.tsx index 2028cdf0..f0aa305f 100644 --- a/frontend/src/connectors/useConnectorLauncher.tsx +++ b/frontend/src/connectors/useConnectorLauncher.tsx @@ -7,7 +7,10 @@ import MCPServerModal from '../modals/MCPServerModal'; import type { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import type { AppDispatch } from '../store'; -import ConnectWizard, { type WizardMode } from './ConnectWizard'; +import ConnectWizard, { + type LaunchPurpose, + type WizardMode, +} from './ConnectWizard'; import { loadConnectors } from './connectorsSlice'; import { isMcpPreset } from './launchRules'; import type { ConnectorDefinition } from './types'; @@ -18,6 +21,11 @@ export type LaunchOptions = { connectionId?: string; /** An existing MCP tool to reconnect through the MCP server form. */ mcpServer?: Record; + /** + * Why the wizard opens. `knowledge` (Add knowledge, Knowledge's Connect a + * service) starts with Sync into Knowledge on; otherwise it starts off. + */ + purpose?: LaunchPurpose; }; type Active = @@ -27,6 +35,7 @@ type Active = mode: WizardMode; connectionId?: string; mcpToolId?: string; + purpose?: LaunchPurpose; } | { kind: 'mcp'; @@ -69,6 +78,7 @@ export default function useConnectorLauncher({ typeof options.mcpServer?.id === 'string' ? options.mcpServer.id : undefined, + purpose: options.purpose, }); return; } @@ -102,6 +112,7 @@ export default function useConnectorLauncher({ connector, mode: options.mode ?? 'connect', connectionId: options.connectionId, + purpose: options.purpose, }); }, [navigate], @@ -142,6 +153,7 @@ export default function useConnectorLauncher({ mode={active.mode} connectionId={active.connectionId} mcpToolId={active.mcpToolId} + purpose={active.purpose} onClose={() => { setActive(null); refresh(); diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index d0a63e83..f78aaf55 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1285,7 +1285,11 @@ "readWrite": "Agenten können Dinge nachschlagen und ändern. Änderungen brauchen deine Freigabe." }, "accountName": "Konto benennen", - "accountNameHint": "Optional. Unterscheidet deine Konten, für dich und die KI, z. B. Alarm-Bot." + "accountNameHint": "Optional. Unterscheidet deine Konten, für dich und die KI, z. B. Alarm-Bot.", + "syncToKnowledge": "Ins Wissen synchronisieren", + "syncToKnowledgeDescription": "Wähle Inhalte aus {{name}}, die Antworten durchsuchen. Bleibt es aus, wird nur verbunden; synchronisieren kannst du später unter Konnektoren.", + "syncLater": "Noch wird nichts ins Wissen synchronisiert. Um Inhalte hinzuzufügen, öffne {{name}} unter Konnektoren und wähle Weitere Inhalte synchronisieren.", + "retrievalSettings": "Erweiterte Abrufeinstellungen" }, "remove": { "title": "{{name}} entfernen?", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 358effbf..0d6c2600 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1291,7 +1291,11 @@ "readWrite": "Agents can look things up and make changes. Changes need your approval." }, "accountName": "Name this account", - "accountNameHint": "Optional. Tells your accounts apart, for you and the AI, e.g. Alerts bot." + "accountNameHint": "Optional. Tells your accounts apart, for you and the AI, e.g. Alerts bot.", + "syncToKnowledge": "Sync into Knowledge", + "syncToKnowledgeDescription": "Pick content from {{name}} for answers to search. Leave it off to only connect; you can sync later from Connectors.", + "syncLater": "Nothing syncs into Knowledge yet. To add content, open {{name}} in Connectors and choose Sync more content.", + "retrievalSettings": "Advanced retrieval settings" }, "remove": { "title": "Remove {{name}}?", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 9d7814f4..5893df20 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1285,7 +1285,11 @@ "readWrite": "Los agentes pueden consultar información y hacer cambios. Los cambios necesitan tu aprobación." }, "accountName": "Nombre de esta cuenta", - "accountNameHint": "Opcional. Distingue tus cuentas, para ti y para la IA; por ejemplo, Bot de alertas." + "accountNameHint": "Opcional. Distingue tus cuentas, para ti y para la IA; por ejemplo, Bot de alertas.", + "syncToKnowledge": "Sincronizar en Conocimiento", + "syncToKnowledgeDescription": "Elige contenido de {{name}} para que las respuestas lo busquen. Desactivado, solo se conecta; puedes sincronizar más tarde desde Conectores.", + "syncLater": "Aún no se sincroniza nada en Conocimiento. Para añadir contenido, abre {{name}} en Conectores y elige Sincronizar más contenido.", + "retrievalSettings": "Configuración avanzada de recuperación" }, "remove": { "title": "¿Eliminar {{name}}?", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 619aeee1..664ea541 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1271,7 +1271,11 @@ "readWrite": "エージェントは情報の参照と変更ができます。変更にはあなたの承認が必要です。" }, "accountName": "このアカウントの名前", - "accountNameHint": "任意。あなたと AI がアカウントを区別できるようにします。例: 通知ボット" + "accountNameHint": "任意。あなたと AI がアカウントを区別できるようにします。例: 通知ボット", + "syncToKnowledge": "ナレッジに同期", + "syncToKnowledgeDescription": "{{name}} から回答で検索するコンテンツを選びます。オフのままなら接続のみ行い、後でコネクタから同期できます。", + "syncLater": "まだナレッジには何も同期されていません。コンテンツを追加するには、コネクタで {{name}} を開き、「さらに同期する」を選択してください。", + "retrievalSettings": "検索の詳細設定" }, "remove": { "title": "{{name}} を削除しますか?", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 8a2f253d..7900e1c0 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1351,7 +1351,11 @@ "readWrite": "Агенты могут находить информацию и вносить изменения. Изменения требуют вашего одобрения." }, "accountName": "Название аккаунта", - "accountNameHint": "Необязательно. Помогает вам и ИИ различать аккаунты, например «Бот оповещений»." + "accountNameHint": "Необязательно. Помогает вам и ИИ различать аккаунты, например «Бот оповещений».", + "syncToKnowledge": "Синхронизировать в знания", + "syncToKnowledgeDescription": "Выберите содержимое {{name}}, по которому будут искаться ответы. Если выключено, сервис только подключается; синхронизировать можно позже в разделе «Коннекторы».", + "syncLater": "В знания пока ничего не синхронизируется. Чтобы добавить содержимое, откройте {{name}} в разделе «Коннекторы» и выберите «Синхронизировать ещё».", + "retrievalSettings": "Расширенные настройки поиска" }, "remove": { "title": "Удалить {{name}}?", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 64027ef1..8ef573ee 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1271,7 +1271,11 @@ "readWrite": "代理可以查詢資訊並進行變更。變更需要你的核准。" }, "accountName": "為此帳戶命名", - "accountNameHint": "選填。讓你和 AI 區分你的帳戶,例如「警示機器人」。" + "accountNameHint": "選填。讓你和 AI 區分你的帳戶,例如「警示機器人」。", + "syncToKnowledge": "同步到知識庫", + "syncToKnowledgeDescription": "從 {{name}} 選擇要在回答中搜尋的內容。關閉時僅建立連線,之後可在連接器中同步。", + "syncLater": "尚未同步任何內容到知識庫。若要新增內容,請在連接器中開啟 {{name}},然後選擇「同步更多內容」。", + "retrievalSettings": "進階檢索設定" }, "remove": { "title": "要移除 {{name}} 嗎?", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 1c72982d..fdc78143 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1271,7 +1271,11 @@ "readWrite": "智能体可以查询信息并进行更改。更改需要你的批准。" }, "accountName": "为此账户命名", - "accountNameHint": "可选。让你和 AI 区分你的账户,例如“告警机器人”。" + "accountNameHint": "可选。让你和 AI 区分你的账户,例如“告警机器人”。", + "syncToKnowledge": "同步到知识库", + "syncToKnowledgeDescription": "从 {{name}} 选择要在回答中搜索的内容。关闭时仅建立连接,之后可在连接器中同步。", + "syncLater": "尚未同步任何内容到知识库。要添加内容,请在连接器中打开 {{name}},然后选择“同步更多内容”。", + "retrievalSettings": "高级检索设置" }, "remove": { "title": "移除 {{name}}?", diff --git a/frontend/src/settings/Connectors.test.tsx b/frontend/src/settings/Connectors.test.tsx index c83e4369..cda9aae6 100644 --- a/frontend/src/settings/Connectors.test.tsx +++ b/frontend/src/settings/Connectors.test.tsx @@ -19,6 +19,12 @@ const service = vi.hoisted(() => ({ })); vi.mock('../api/services/connectorsService', () => ({ default: service })); +// The wizard is tested on its own; here only what it is opened with matters. +const launch = vi.hoisted(() => vi.fn()); +vi.mock('../connectors/useConnectorLauncher', () => ({ + default: () => ({ launch, modals: null }), +})); + import connectorsReducer from '../connectors/connectorsSlice'; import type { ConnectorDefinition } from '../connectors/types'; import Connectors from './Connectors'; @@ -247,6 +253,62 @@ describe('Connectors page', () => { expect(pills).not.toContain('settings.connectors.categories.messaging'); }); + describe('opened for Knowledge', () => { + const S3 = definition({ + key: 's3', + name: 'Amazon S3', + category: 'files', + capabilities: ['sync'], + sync_ingestor: 's3', + tool_templates: [], + setup: { tools: 'off', sync: 'ask' }, + }); + + beforeEach(() => { + launch.mockClear(); + service.getCatalog.mockResolvedValue({ + success: true, + connectors: [...CATALOG, S3], + }); + }); + + it('connects with syncing switched on from a sync-only list', async () => { + await render('/settings/connectors?capability=sync'); + await act(async () => card('s3')!.click()); + expect(launch).toHaveBeenCalledWith( + expect.objectContaining({ key: 's3' }), + { purpose: 'knowledge' }, + ); + }); + + it('leaves the choice off on a plain visit', async () => { + await render(); + await act(async () => card('s3')!.click()); + expect(launch).toHaveBeenCalledWith( + expect.objectContaining({ key: 's3' }), + {}, + ); + }); + + it("carries it through the connector's page", async () => { + await render('/settings/connectors?capability=sync'); + await act(async () => card('google_drive')!.click()); + // No account of the user's own yet: the page offers Connect. + const connect = Array.from( + document.body.querySelectorAll( + '[role="dialog"] button', + ), + ).find( + (b) => b.textContent?.trim() === 'settings.connectors.status.connect', + )!; + await act(async () => connect.click()); + expect(launch).toHaveBeenCalledWith( + expect.objectContaining({ key: 'google_drive' }), + { purpose: 'knowledge' }, + ); + }); + }); + const ATLASSIAN = definition({ key: 'mcp:atlassian', name: 'Jira & Confluence', diff --git a/frontend/src/settings/Connectors.tsx b/frontend/src/settings/Connectors.tsx index c3828faf..c8558e90 100644 --- a/frontend/src/settings/Connectors.tsx +++ b/frontend/src/settings/Connectors.tsx @@ -34,7 +34,9 @@ import { } from '../connectors/connectorsSlice'; import { connectorDescription, connectorName } from '../connectors/i18n'; import type { ConnectorDefinition } from '../connectors/types'; -import useConnectorLauncher from '../connectors/useConnectorLauncher'; +import useConnectorLauncher, { + type LaunchOptions, +} from '../connectors/useConnectorLauncher'; import { selectToken } from '../preferences/preferenceSlice'; import type { AppDispatch } from '../store'; @@ -129,6 +131,10 @@ export default function Connectors() { searchParams.delete('capability'); setSearchParams(searchParams, { replace: true }); }; + // Listed for syncing (Knowledge's Connect a service, the sources picker's + // Connect more): a connect starts with Sync into Knowledge on. + const withPurpose = (options: LaunchOptions = {}): LaunchOptions => + capability === 'sync' ? { ...options, purpose: 'knowledge' } : options; // Only categories that have something in them once the composer's // capability filter applies (hidden connectors can empty one too), so no @@ -178,7 +184,7 @@ export default function Connectors() { !hasParts && (connector.state === 'available' || connector.state === 'custom') ) { - launch(connector); + launch(connector, withPurpose()); return; } setOpenKey(connector.key); @@ -329,7 +335,7 @@ export default function Connectors() { onClose={closeDrawer} onConnect={(connector, options) => { closeDrawer(); - launch(connector, options); + launch(connector, withPurpose(options)); }} /> {modals} diff --git a/frontend/src/settings/components/RetrievalOptions.tsx b/frontend/src/settings/components/RetrievalOptions.tsx index 054c850d..5a7d52d9 100644 --- a/frontend/src/settings/components/RetrievalOptions.tsx +++ b/frontend/src/settings/components/RetrievalOptions.tsx @@ -319,6 +319,9 @@ type RetrievalOptionsProps = { // the retrieval tester, where those knobs cannot affect the result and // showing them would imply they do. queryOnly?: boolean; + // The collapsible toggle's label, where "Advanced settings" alone would be + // unclear (the connect wizard). Defaults to the shared title. + title?: string; }; /** @@ -335,6 +338,7 @@ export default function RetrievalOptions({ graphRAGAvailable = false, availableModels = [], queryOnly = false, + title, }: RetrievalOptionsProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -859,6 +863,7 @@ export default function RetrievalOptions({ variant="link" size="sm" onClick={() => setOpen((o) => !o)} + aria-expanded={expanded} className="-ml-3 w-fit justify-start" > - {tr('title')} + {title ?? tr('title')}
([]); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + userService + .getConfig() + .then((response) => response.json()) + .then((config) => { + if (!cancelled) { + setGraphRAGAvailable(!!config?.graphrag_available); + setHybridAvailable(!!config?.hybrid_available); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [enabled]); + + // Models back the graphrag extraction-model picker; only fetched when the + // instance supports graphrag. + useEffect(() => { + if (!enabled || !graphRAGAvailable) return; + let cancelled = false; + modelService + .getModels(token) + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + if (!cancelled && data) + setAvailableModels(modelService.transformModels(data.models || [])); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [enabled, graphRAGAvailable, token]); + + return { graphRAGAvailable, hybridAvailable, availableModels }; +} diff --git a/frontend/src/upload/Upload.test.tsx b/frontend/src/upload/Upload.test.tsx index e533a21c..c688ca01 100644 --- a/frontend/src/upload/Upload.test.tsx +++ b/frontend/src/upload/Upload.test.tsx @@ -237,11 +237,32 @@ describe('Upload source-type tiles', () => { expect(launch).toHaveBeenCalledWith(s3, { mode: 'sync', connectionId: 'k1', + purpose: 'knowledge', }); connectorsState.catalog = []; connectorsState.connections = []; }); + it('connects a new account for Knowledge, syncing switched on', async () => { + launch.mockClear(); + const s3 = { + key: 's3', + icon: 's3', + sync_ingestor: 's3', + auth_kind: 'api_key', + available: true, + missing_settings: [], + }; + connectorsState.catalog = [s3]; + await render(); + const tile = tiles().find((t) => + t.textContent?.includes('ingestors.s3.label'), + )!; + await act(async () => tile.click()); + expect(launch).toHaveBeenCalledWith(s3, { purpose: 'knowledge' }); + connectorsState.catalog = []; + }); + describe('GitHub', () => { const githubConnector = { key: 'github', @@ -285,7 +306,9 @@ describe('Upload source-type tiles', () => { 'modals.uploadDoc.github.privateHint', ); await act(async () => handOver()!.click()); - expect(launch).toHaveBeenCalledWith(githubConnector, {}); + expect(launch).toHaveBeenCalledWith(githubConnector, { + purpose: 'knowledge', + }); }); it('goes straight to picking a repository with a connected account', async () => { @@ -307,6 +330,7 @@ describe('Upload source-type tiles', () => { expect(launch).toHaveBeenCalledWith(githubConnector, { mode: 'sync', connectionId: 'gh-1', + purpose: 'knowledge', }); }); diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 44f2a66f..775616e3 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -9,8 +9,6 @@ import { useDispatch, useSelector, useStore } from 'react-redux'; import type { RootState } from '../store'; import userService from '../api/services/userService'; -import modelService from '../api/services/modelService'; -import type { Model } from '../models/types'; import { Alert, AlertDescription } from '../components/ui/alert'; import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; @@ -75,6 +73,7 @@ import RetrievalOptions, { optionsToConfig, type RetrievalOptionsValue, } from '../settings/components/RetrievalOptions'; +import useRetrievalAvailability from '../settings/components/useRetrievalAvailability'; /** Per-file limit for local uploads (25 MB), enforced by the dropzone. */ const MAX_UPLOAD_BYTES = 25000000; @@ -126,9 +125,8 @@ function Upload({ const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [retrievalOptions, setRetrievalOptions] = useState(DEFAULT_RETRIEVAL_OPTIONS); - const [graphRAGAvailable, setGraphRAGAvailable] = useState(false); - const [hybridAvailable, setHybridAvailable] = useState(false); - const [availableModels, setAvailableModels] = useState([]); + const { graphRAGAvailable, hybridAvailable, availableModels } = + useRetrievalAvailability(token); // File picker state const [selectedFiles, setSelectedFiles] = useState([]); @@ -137,41 +135,6 @@ function Upload({ // and Reddit pick it here ('' means "enter new credentials"). const [connectionId, setConnectionId] = useState(null); - useEffect(() => { - let cancelled = false; - userService - .getConfig() - .then((response) => response.json()) - .then((config) => { - if (!cancelled) { - setGraphRAGAvailable(!!config?.graphrag_available); - setHybridAvailable(!!config?.hybrid_available); - } - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, []); - - // Models back the graphrag extraction-model picker; only fetched when the - // instance supports graphrag. - useEffect(() => { - if (!graphRAGAvailable) return; - let cancelled = false; - modelService - .getModels(token) - .then((response) => (response.ok ? response.json() : null)) - .then((data) => { - if (!cancelled && data) - setAvailableModels(modelService.transformModels(data.models || [])); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [graphRAGAvailable, token]); - const renderFormFields = () => { if (!ingestor.type) return null; const ingestorSchema = getIngestorSchema(ingestor.type as IngestorType); @@ -1281,11 +1244,16 @@ function Upload({ c.status === 'connected', ); setHandedOver(true); + // Opened to add knowledge: syncing starts switched on. launch( connector, account - ? { mode: 'sync', connectionId: account.id } - : {}, + ? { + mode: 'sync', + connectionId: account.id, + purpose: 'knowledge', + } + : { purpose: 'knowledge' }, ); return; } @@ -1335,8 +1303,12 @@ function Upload({ launch( githubConnector, githubAccount - ? { mode: 'sync', connectionId: githubAccount.id } - : {}, + ? { + mode: 'sync', + connectionId: githubAccount.id, + purpose: 'knowledge', + } + : { purpose: 'knowledge' }, ); }} >