Ask before syncing into Knowledge when connecting a service

A first connect of a service that can sync now asks with a Sync into
Knowledge switch. It starts off from the Connectors page, Add tool and
the chat, and on when the wizard opens for Knowledge: the Add knowledge
tiles and the Connectors page listed for syncing. Off, the account is
still connected and the summary says where to sync later. On, the
picker, name and frequency show with collapsed Advanced retrieval
settings, sent with the sync; an incoherent prescreen blocks it as in
Upload. Sync more and reconnect are unchanged.
This commit is contained in:
arc53-machine committed 2026-09-29 12:17:33 +01:00
1 parent 54ba7c7b9b
commit 18cc6443fc
18 files changed
+647 -173

No files matched your search

@@ -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<string, unknown>;
frequency?: string;
name?: string;
/** The source's retrieval settings, as an upload sends them. */
config?: SourceConfig;
};
};
+187 -6
View File
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('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<HTMLInputElement>('#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<HTMLButtonElement>('#retrieval-prescreen')!
.click(),
);
// More chunks than prescreen candidates.
await typeInto(
document.body.querySelector<HTMLInputElement>('#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<HTMLInputElement>(
'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<HTMLButtonElement>('[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<HTMLButtonElement>('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();
});
});
});
+201 -109
View File
@@ -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<RetrievalOptionsValue>(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 = () => (
<div className="flex flex-col gap-5">
@@ -559,29 +615,31 @@ export default function ConnectWizard({
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{offerTools && (
{(offerTools || askSync) && (
<SettingRows>
<SettingRow
label={t('settings.connectors.wizard.addTools', {
name,
interpolation: { escapeValue: false },
})}
description={t(
`settings.connectors.wizard.addToolsDescription.${
connector.capabilities.includes('write') || wantsWrites
? 'readWrite'
: 'read'
}`,
)}
htmlFor={`tools-${connector.key}`}
alignStart
>
<Switch
id={`tools-${connector.key}`}
checked={toolsOn}
onCheckedChange={(checked) => setToolsOn(checked === true)}
/>
</SettingRow>
{offerTools && (
<SettingRow
label={t('settings.connectors.wizard.addTools', {
name,
interpolation: { escapeValue: false },
})}
description={t(
`settings.connectors.wizard.addToolsDescription.${
connector.capabilities.includes('write') || wantsWrites
? 'readWrite'
: 'read'
}`,
)}
htmlFor={`tools-${connector.key}`}
alignStart
>
<Switch
id={`tools-${connector.key}`}
checked={toolsOn}
onCheckedChange={(checked) => setToolsOn(checked === true)}
/>
</SettingRow>
)}
{offerWrites && (
<SettingRow
label={t('settings.connectors.github.writes')}
@@ -596,87 +654,116 @@ export default function ConnectWizard({
/>
</SettingRow>
)}
{askSync && (
<SettingRow
label={t('settings.connectors.wizard.syncToKnowledge')}
description={t(
'settings.connectors.wizard.syncToKnowledgeDescription',
{ name, interpolation: { escapeValue: false } },
)}
htmlFor={`knowledge-${connector.key}`}
alignStart
>
<Switch
id={`knowledge-${connector.key}`}
checked={syncOn}
onCheckedChange={(checked) => toggleSync(checked === true)}
/>
</SettingRow>
)}
</SettingRows>
)}
{isRepoPicker && connectionId ? (
<RepoPicker
connectionId={connectionId}
token={token}
value={selectedRepo}
onChange={(fullName) => {
setSelectedRepo(fullName);
if (!nameTouched) setSourceName(fullName);
}}
/>
) : isLinearPicker && connectionId ? (
<LinearPicker
connectionId={connectionId}
token={token}
value={linearSelection}
onChange={(selection) => {
setLinearSelection(selection);
if (!nameTouched) setSourceName(linearSourceName(selection));
}}
/>
) : connector.key === 'google_drive' &&
envVar('VITE_GOOGLE_CLIENT_ID') ? (
<GoogleDrivePicker
token={token}
connectionId={connectionId}
onFirstPickName={prefillName}
onSelectionChange={(fileIds, folderIds = []) => {
setSelectedFiles(fileIds);
setSelectedFolders(folderIds);
}}
/>
) : PICKER_CONNECTORS.has(connector.key) ? (
<FilePicker
provider={connector.key}
token={token}
connectionId={connectionId}
onFirstPickName={prefillName}
onSelectionChange={(fileIds, folderIds = []) => {
setSelectedFiles(fileIds);
setSelectedFolders(folderIds);
}}
/>
) : (
<CredentialForm
connectorKey={connector.key}
idPrefix={`sync-${connector.key}`}
fields={connector.setup_fields}
values={setupValues}
onChange={(values) => {
setSetupValues(values);
const first = Object.values(values).find(Boolean);
if (first) prefillName(first);
}}
/>
{syncing && (
<>
{isRepoPicker && connectionId ? (
<RepoPicker
connectionId={connectionId}
token={token}
value={selectedRepo}
onChange={(fullName) => {
setSelectedRepo(fullName);
if (!nameTouched) setSourceName(fullName);
}}
/>
) : isLinearPicker && connectionId ? (
<LinearPicker
connectionId={connectionId}
token={token}
value={linearSelection}
onChange={(selection) => {
setLinearSelection(selection);
if (!nameTouched) setSourceName(linearSourceName(selection));
}}
/>
) : connector.key === 'google_drive' &&
envVar('VITE_GOOGLE_CLIENT_ID') ? (
<GoogleDrivePicker
token={token}
connectionId={connectionId}
onFirstPickName={prefillName}
onSelectionChange={(fileIds, folderIds = []) => {
setSelectedFiles(fileIds);
setSelectedFolders(folderIds);
}}
/>
) : PICKER_CONNECTORS.has(connector.key) ? (
<FilePicker
provider={connector.key}
token={token}
connectionId={connectionId}
onFirstPickName={prefillName}
onSelectionChange={(fileIds, folderIds = []) => {
setSelectedFiles(fileIds);
setSelectedFolders(folderIds);
}}
/>
) : (
<CredentialForm
connectorKey={connector.key}
idPrefix={`sync-${connector.key}`}
fields={connector.setup_fields}
values={setupValues}
onChange={(values) => {
setSetupValues(values);
const first = Object.values(values).find(Boolean);
if (first) prefillName(first);
}}
/>
)}
<div className="grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-2">
<Input
label={t('modals.uploadDoc.name')}
value={sourceName}
onChange={(e) => {
setNameTouched(true);
setSourceName(e.target.value);
}}
/>
<FormField label={t('settings.connectors.wizard.syncFrequency')}>
<Select value={frequency} onValueChange={setFrequency}>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCIES.map((value) => (
<SelectItem key={value} value={value}>
{t(`settings.sources.syncFrequency.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<RetrievalOptions
title={t('settings.connectors.wizard.retrievalSettings')}
value={retrievalOptions}
onChange={setRetrievalOptions}
hybridAvailable={hybridAvailable}
graphRAGAvailable={graphRAGAvailable}
availableModels={availableModels}
/>
</>
)}
<div className="grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-2">
<Input
label={t('modals.uploadDoc.name')}
value={sourceName}
onChange={(e) => {
setNameTouched(true);
setSourceName(e.target.value);
}}
/>
<FormField label={t('settings.connectors.wizard.syncFrequency')}>
<Select value={frequency} onValueChange={setFrequency}>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCIES.map((value) => (
<SelectItem key={value} value={value}>
{t(`settings.sources.syncFrequency.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
</div>
);
@@ -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.
<Button type="button" size="lg" shape="pill" onClick={addSource}>
{t('settings.connectors.wizard.continue')}
</Button>
) : step === 'setup' ? (
<ModalActions
cancelLabel={t('settings.connectors.wizard.skip')}
@@ -9,10 +9,16 @@ vi.mock('./ConnectWizard', () => ({
default: ({
mode,
connectionId,
purpose,
}: {
mode: string;
connectionId?: string;
}) => <div data-testid="wizard">{`${mode}:${connectionId ?? ''}`}</div>,
purpose?: string;
}) => (
<div data-testid="wizard" data-purpose={purpose ?? ''}>
{`${mode}:${connectionId ?? ''}`}
</div>
),
}));
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('');
});
});
@@ -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<string, unknown>;
/**
* 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();
+5 -1
View File
@@ -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?",
+5 -1
View File
@@ -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}}?",
+5 -1
View File
@@ -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}}?",
+5 -1
View File
@@ -1271,7 +1271,11 @@
"readWrite": "エージェントは情報の参照と変更ができます。変更にはあなたの承認が必要です。"
},
"accountName": "このアカウントの名前",
"accountNameHint": "任意。あなたと AI がアカウントを区別できるようにします。例: 通知ボット"
"accountNameHint": "任意。あなたと AI がアカウントを区別できるようにします。例: 通知ボット",
"syncToKnowledge": "ナレッジに同期",
"syncToKnowledgeDescription": "{{name}} から回答で検索するコンテンツを選びます。オフのままなら接続のみ行い、後でコネクタから同期できます。",
"syncLater": "まだナレッジには何も同期されていません。コンテンツを追加するには、コネクタで {{name}} を開き、「さらに同期する」を選択してください。",
"retrievalSettings": "検索の詳細設定"
},
"remove": {
"title": "{{name}} を削除しますか?",
+5 -1
View File
@@ -1351,7 +1351,11 @@
"readWrite": "Агенты могут находить информацию и вносить изменения. Изменения требуют вашего одобрения."
},
"accountName": "Название аккаунта",
"accountNameHint": "Необязательно. Помогает вам и ИИ различать аккаунты, например «Бот оповещений»."
"accountNameHint": "Необязательно. Помогает вам и ИИ различать аккаунты, например «Бот оповещений».",
"syncToKnowledge": "Синхронизировать в знания",
"syncToKnowledgeDescription": "Выберите содержимое {{name}}, по которому будут искаться ответы. Если выключено, сервис только подключается; синхронизировать можно позже в разделе «Коннекторы».",
"syncLater": "В знания пока ничего не синхронизируется. Чтобы добавить содержимое, откройте {{name}} в разделе «Коннекторы» и выберите «Синхронизировать ещё».",
"retrievalSettings": "Расширенные настройки поиска"
},
"remove": {
"title": "Удалить {{name}}?",
+5 -1
View File
@@ -1271,7 +1271,11 @@
"readWrite": "代理可以查詢資訊並進行變更。變更需要你的核准。"
},
"accountName": "為此帳戶命名",
"accountNameHint": "選填。讓你和 AI 區分你的帳戶,例如「警示機器人」。"
"accountNameHint": "選填。讓你和 AI 區分你的帳戶,例如「警示機器人」。",
"syncToKnowledge": "同步到知識庫",
"syncToKnowledgeDescription": "從 {{name}} 選擇要在回答中搜尋的內容。關閉時僅建立連線,之後可在連接器中同步。",
"syncLater": "尚未同步任何內容到知識庫。若要新增內容,請在連接器中開啟 {{name}},然後選擇「同步更多內容」。",
"retrievalSettings": "進階檢索設定"
},
"remove": {
"title": "要移除 {{name}} 嗎?",
+5 -1
View File
@@ -1271,7 +1271,11 @@
"readWrite": "智能体可以查询信息并进行更改。更改需要你的批准。"
},
"accountName": "为此账户命名",
"accountNameHint": "可选。让你和 AI 区分你的账户,例如“告警机器人”。"
"accountNameHint": "可选。让你和 AI 区分你的账户,例如“告警机器人”。",
"syncToKnowledge": "同步到知识库",
"syncToKnowledgeDescription": "从 {{name}} 选择要在回答中搜索的内容。关闭时仅建立连接,之后可在连接器中同步。",
"syncLater": "尚未同步任何内容到知识库。要添加内容,请在连接器中打开 {{name}},然后选择“同步更多内容”。",
"retrievalSettings": "高级检索设置"
},
"remove": {
"title": "移除 {{name}}?",
+62
View File
@@ -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<HTMLButtonElement>(
'[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',
+9 -3
View File
@@ -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}
@@ -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"
>
<ChevronRight
@@ -868,7 +873,7 @@ export default function RetrievalOptions({
expanded && 'rotate-90',
)}
/>
<span>{tr('title')}</span>
<span>{title ?? tr('title')}</span>
</Button>
<div
className={cn(
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import modelService from '../../api/services/modelService';
import userService from '../../api/services/userService';
import type { Model } from '../../models/types';
/**
* What RetrievalOptions may offer on this instance: the hybrid and graphrag
* retrievers (from /api/config) and, when graphrag is on, the models for its
* extraction-model picker. Nothing is fetched until `enabled`.
*/
export default function useRetrievalAvailability(
token: string | null,
enabled = true,
) {
const [graphRAGAvailable, setGraphRAGAvailable] = useState(false);
const [hybridAvailable, setHybridAvailable] = useState(false);
const [availableModels, setAvailableModels] = useState<Model[]>([]);
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 };
}
+25 -1
View File
@@ -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',
});
});
+16 -44
View File
@@ -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<RetrievalOptionsValue>(DEFAULT_RETRIEVAL_OPTIONS);
const [graphRAGAvailable, setGraphRAGAvailable] = useState(false);
const [hybridAvailable, setHybridAvailable] = useState(false);
const [availableModels, setAvailableModels] = useState<Model[]>([]);
const { graphRAGAvailable, hybridAvailable, availableModels } =
useRetrievalAvailability(token);
// File picker state
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
@@ -137,41 +135,6 @@ function Upload({
// and Reddit pick it here ('' means "enter new credentials").
const [connectionId, setConnectionId] = useState<string | null>(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' },
);
}}
>