mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-19 20:14:32 +00:00
One connect flow and one name from every entry point
- Add Source hands a connection tile to the connect wizard: an existing
account goes straight to choosing what to sync, otherwise it connects
one. The legacy per-service forms stay only for installs without
connectors, so S3 no longer has two different forms.
- Add Tool lists services from the connector catalog first, MCP presets
included, each with its connection state; a connected one opens its
drawer, the rest start the wizard. Built-in tools follow.
- Service tools carry the connector's name everywhere ("Telegram", not
"Telegram Bot"), and the composer groups custom tools (API tool, MCP
servers) as Custom, like the agent builder.
- OpenAPI opens the API tool editor as an unsaved draft; the tool is
created on its first save, so exploring the menu leaves nothing behind.
This commit is contained in:
1 parent
bfcc4a9773
commit
23229181f1
18 files changed
+434
-97
No files matched your search
@@ -224,6 +224,8 @@ class AvailableTools(Resource):
|
||||
)):
|
||||
continue
|
||||
group, connector_key = "service", definition.key
|
||||
# One name everywhere: the connector's, not the tool's own.
|
||||
name = definition.name
|
||||
elif tool_name in _CUSTOM_CONNECTOR_TOOLS:
|
||||
group, connector_key = "custom", _CUSTOM_CONNECTOR_TOOLS[tool_name]
|
||||
else:
|
||||
|
||||
@@ -1580,11 +1580,15 @@ export default function MessageInput({
|
||||
? connections.find((c) => c.id === tool.connection_id)
|
||||
: undefined;
|
||||
const anyConnectedTool = userTools.some((tool) => toolConnection(tool));
|
||||
// Same groups as the agent builder: built in, one per service, then custom
|
||||
// tools (an API tool, an MCP server with no connection).
|
||||
const isCustomTool = (tool: UserToolType) =>
|
||||
!toolConnection(tool) &&
|
||||
(tool.name === 'api_tool' || tool.name === 'mcp_tool');
|
||||
const toolRank = (tool: UserToolType) =>
|
||||
toolConnection(tool) ? 1 : isCustomTool(tool) ? 2 : 0;
|
||||
const toolItems: MultiSelectPopoverItem[] = [...userTools]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(Boolean(toolConnection(a))) - Number(Boolean(toolConnection(b))),
|
||||
)
|
||||
.sort((a, b) => toolRank(a) - toolRank(b))
|
||||
.map((tool) => {
|
||||
const connection = toolConnection(tool);
|
||||
return {
|
||||
@@ -1596,7 +1600,10 @@ export default function MessageInput({
|
||||
<ToolIcon name={tool.name} className="size-5" />
|
||||
),
|
||||
group: anyConnectedTool
|
||||
? (connection?.name ?? t('settings.tools.groupBuiltIn'))
|
||||
? (connection?.name ??
|
||||
(isCustomTool(tool)
|
||||
? t('agents.form.toolsPopup.groupCustom')
|
||||
: t('settings.tools.groupBuiltIn')))
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import connectorsService from '../api/services/connectorsService';
|
||||
import userService from '../api/services/userService';
|
||||
import MCPServerModal from '../modals/MCPServerModal';
|
||||
import type { AvailableToolType } from '../modals/types';
|
||||
import type { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import type { AppDispatch } from '../store';
|
||||
import ConnectWizard, { type WizardMode } from './ConnectWizard';
|
||||
@@ -50,7 +46,6 @@ const isMcp = (connector: ConnectorDefinition) =>
|
||||
export default function useConnectorLauncher({
|
||||
onConnected,
|
||||
}: { onConnected?: () => void } = {}) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const navigate = useNavigate();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -96,37 +91,9 @@ export default function useConnectorLauncher({
|
||||
return;
|
||||
}
|
||||
if (connector.key === 'custom_openapi') {
|
||||
// An OpenAPI tool starts empty; its spec import is today's API Tool
|
||||
// screen on the Tools page.
|
||||
try {
|
||||
const response = await userService.getAvailableTools(token);
|
||||
const data = await response.json();
|
||||
const tool = (data.data as AvailableToolType[] | undefined)?.find(
|
||||
(candidate) => candidate.name === 'api_tool',
|
||||
);
|
||||
if (!tool) throw new Error('api_tool is not available');
|
||||
const created = await userService.createTool(
|
||||
{
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
description: tool.description,
|
||||
config: {},
|
||||
actions: tool.actions,
|
||||
status: true,
|
||||
},
|
||||
token,
|
||||
);
|
||||
const body = await created.json();
|
||||
if (!body?.id) throw new Error('create failed');
|
||||
navigate('/settings/tools', { state: { openToolId: body.id } });
|
||||
} catch {
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('settings.connectors.createFailed'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The API tool screen on the Tools page imports the spec; the tool
|
||||
// is only created when it is saved there.
|
||||
navigate('/settings/tools', { state: { newApiTool: true } });
|
||||
return;
|
||||
}
|
||||
setActive({
|
||||
@@ -136,7 +103,7 @@ export default function useConnectorLauncher({
|
||||
connectionId: options.connectionId,
|
||||
});
|
||||
},
|
||||
[dispatch, navigate, t, token],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const afterMcpSave = async () => {
|
||||
|
||||
@@ -860,7 +860,7 @@
|
||||
"toolIconTitle": "{{name}}-Symbol",
|
||||
"groupBuiltIn": "Integriert",
|
||||
"groupService": "Aus einem Dienst",
|
||||
"browseConnectors": "Suchst du Notion, Linear oder einen anderen Dienst? Konnektoren durchsuchen",
|
||||
"browseConnectors": "Alle Konnektoren ansehen",
|
||||
"viaConnection": "über {{name}}"
|
||||
},
|
||||
"devices": {
|
||||
|
||||
@@ -866,7 +866,7 @@
|
||||
"toolIconTitle": "{{name}} icon",
|
||||
"groupBuiltIn": "Built in",
|
||||
"groupService": "From a service",
|
||||
"browseConnectors": "Looking for Notion, Linear or another service? Browse connectors",
|
||||
"browseConnectors": "Browse all connectors",
|
||||
"viaConnection": "via {{name}}"
|
||||
},
|
||||
"devices": {
|
||||
|
||||
@@ -860,7 +860,7 @@
|
||||
"toolIconTitle": "Icono de {{name}}",
|
||||
"groupBuiltIn": "Integradas",
|
||||
"groupService": "De un servicio",
|
||||
"browseConnectors": "¿Buscas Notion, Linear u otro servicio? Explora los conectores",
|
||||
"browseConnectors": "Ver todos los conectores",
|
||||
"viaConnection": "mediante {{name}}"
|
||||
},
|
||||
"devices": {
|
||||
|
||||
@@ -851,7 +851,7 @@
|
||||
"toolIconTitle": "{{name}}のアイコン",
|
||||
"groupBuiltIn": "組み込み",
|
||||
"groupService": "サービスから",
|
||||
"browseConnectors": "Notion、Linear などのサービスをお探しですか?コネクタを見る",
|
||||
"browseConnectors": "すべてのコネクタを見る",
|
||||
"viaConnection": "{{name}} 経由"
|
||||
},
|
||||
"devices": {
|
||||
|
||||
@@ -902,7 +902,7 @@
|
||||
"toolIconTitle": "Значок {{name}}",
|
||||
"groupBuiltIn": "Встроенные",
|
||||
"groupService": "Из сервиса",
|
||||
"browseConnectors": "Ищете Notion, Linear или другой сервис? Смотрите коннекторы",
|
||||
"browseConnectors": "Все коннекторы",
|
||||
"viaConnection": "через {{name}}"
|
||||
},
|
||||
"devices": {
|
||||
|
||||
@@ -851,7 +851,7 @@
|
||||
"toolIconTitle": "{{name}} 圖示",
|
||||
"groupBuiltIn": "內建",
|
||||
"groupService": "來自服務",
|
||||
"browseConnectors": "在找 Notion、Linear 或其他服務嗎?瀏覽連接器",
|
||||
"browseConnectors": "瀏覽所有連接器",
|
||||
"viaConnection": "透過 {{name}}"
|
||||
},
|
||||
"devices": {
|
||||
|
||||
@@ -851,7 +851,7 @@
|
||||
"toolIconTitle": "{{name}} 图标",
|
||||
"groupBuiltIn": "内置",
|
||||
"groupService": "来自服务",
|
||||
"browseConnectors": "在找 Notion、Linear 或其他服务?浏览连接器",
|
||||
"browseConnectors": "浏览所有连接器",
|
||||
"viaConnection": "通过 {{name}}"
|
||||
},
|
||||
"devices": {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getAvailableTools: vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
name: 'memory',
|
||||
displayName: 'Memory',
|
||||
description: 'Remembers',
|
||||
configRequirements: {},
|
||||
actions: [],
|
||||
group: 'built_in',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/connectorsService', () => ({
|
||||
default: {
|
||||
getCatalog: vi.fn(async () => ({
|
||||
success: true,
|
||||
connectors: server.catalog,
|
||||
})),
|
||||
listConnections: vi.fn(async () => ({ success: true, connections: [] })),
|
||||
},
|
||||
}));
|
||||
|
||||
const launch = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../connectors/useConnectorLauncher', () => ({
|
||||
default: () => ({ launch, modals: null }),
|
||||
}));
|
||||
|
||||
import connectorsReducer from '../connectors/connectorsSlice';
|
||||
import AddToolModal from './AddToolModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const entry = (overrides: Record<string, unknown>) => ({
|
||||
description: '',
|
||||
category: 'dev',
|
||||
publisher: 'built_in',
|
||||
capabilities: ['read', 'write'],
|
||||
available: true,
|
||||
connection_count: 0,
|
||||
connected_count: 0,
|
||||
state: 'available',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const server = vi.hoisted(() => ({ catalog: [] as unknown[] }));
|
||||
|
||||
describe('AddToolModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
launch.mockClear();
|
||||
server.catalog = [
|
||||
entry({
|
||||
key: 'mcp:notion',
|
||||
name: 'Notion',
|
||||
icon: 'notion',
|
||||
publisher: 'preset',
|
||||
}),
|
||||
entry({
|
||||
key: 'telegram',
|
||||
name: 'Telegram',
|
||||
icon: 'tool_telegram',
|
||||
connection_count: 1,
|
||||
connected_count: 1,
|
||||
state: 'connected',
|
||||
}),
|
||||
entry({
|
||||
key: 'google_drive',
|
||||
name: 'Google Drive',
|
||||
icon: 'drive',
|
||||
capabilities: ['sync'],
|
||||
}),
|
||||
entry({
|
||||
key: 'custom_mcp',
|
||||
name: 'MCP server',
|
||||
icon: 'mcp',
|
||||
publisher: 'custom',
|
||||
}),
|
||||
];
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
connectors: connectorsReducer,
|
||||
preference: (state = { token: null }) => state,
|
||||
},
|
||||
});
|
||||
const setModalState = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/settings/tools']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings/tools"
|
||||
element={
|
||||
<AddToolModal
|
||||
message=""
|
||||
modalState="ACTIVE"
|
||||
setModalState={setModalState}
|
||||
getUserTools={vi.fn()}
|
||||
onToolAdded={vi.fn()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings/connectors" element={<div>DRAWER</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
// The loader holds its skeleton for 250 ms after the tools arrive.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
return setModalState;
|
||||
};
|
||||
|
||||
const service = (key: string) =>
|
||||
document.body.querySelector<HTMLButtonElement>(
|
||||
`[data-testid="add-tool-service-${key}"]`,
|
||||
);
|
||||
|
||||
it('lists services, presets included, before built-in tools', async () => {
|
||||
await render();
|
||||
const text = document.body.textContent ?? '';
|
||||
const services = text.indexOf('settings.tools.groupService');
|
||||
expect(services).toBeGreaterThan(-1);
|
||||
expect(services).toBeLessThan(text.indexOf('settings.tools.groupBuiltIn'));
|
||||
expect(service('mcp:notion')).not.toBeNull();
|
||||
// Sync-only and custom connectors are not tools here.
|
||||
expect(service('google_drive')).toBeNull();
|
||||
expect(service('custom_mcp')).toBeNull();
|
||||
});
|
||||
|
||||
it('connects a service that is not connected yet', async () => {
|
||||
await render();
|
||||
await act(async () => service('mcp:notion')!.click());
|
||||
expect(launch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'mcp:notion' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a connected service to manage its tools', async () => {
|
||||
await render();
|
||||
await act(async () => service('telegram')!.click());
|
||||
expect(launch).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain('DRAWER');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,12 @@ import userService from '../api/services/userService';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import ToolIcon from '../components/ToolIcon';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card, CardDescription, CardTitle } from '../components/ui/card';
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
} from '../components/ui/card';
|
||||
import { Modal, ModalActions } from '../components/ui/modal';
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
import { useLoaderState } from '../hooks';
|
||||
@@ -16,6 +21,10 @@ import {
|
||||
loadConnectors,
|
||||
selectConnectorCatalog,
|
||||
} from '../connectors/connectorsSlice';
|
||||
import { ConnectorStateBadge } from '../connectors/ConnectorCard';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import { connectorDescription, connectorName } from '../connectors/i18n';
|
||||
import type { ConnectorDefinition } from '../connectors/types';
|
||||
import useConnectorLauncher from '../connectors/useConnectorLauncher';
|
||||
import PairDeviceModal from '../settings/PairDeviceModal';
|
||||
import type { AppDispatch } from '../store';
|
||||
@@ -75,6 +84,30 @@ export default function AddToolModal({
|
||||
});
|
||||
};
|
||||
|
||||
// Services come from the connector catalog (tool connectors and MCP
|
||||
// presets): they are added by connecting the service, first in the list.
|
||||
const services = catalog.filter(
|
||||
(connector) =>
|
||||
connector.publisher !== 'custom' &&
|
||||
connector.available &&
|
||||
connector.capabilities.some((c) => c === 'read' || c === 'write'),
|
||||
);
|
||||
const builtIn = availableTools.filter(
|
||||
(tool) => (tool.group ?? 'built_in') === 'built_in',
|
||||
);
|
||||
|
||||
const openService = (connector: ConnectorDefinition) => {
|
||||
setModalState('INACTIVE');
|
||||
// Already connected: its tools exist; open its drawer to manage them.
|
||||
if (connector.connection_count > 0) {
|
||||
navigate(
|
||||
`/settings/connectors?connector=${encodeURIComponent(connector.key)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
launch(connector);
|
||||
};
|
||||
|
||||
const handleAddTool = (tool: AvailableToolType) => {
|
||||
// ``remote_device`` is created server-side via the pairing redeem
|
||||
// endpoint, not the standard create_tool path.
|
||||
@@ -171,24 +204,60 @@ export default function AddToolModal({
|
||||
<SkeletonLoader component="addToolCards" count={6} />
|
||||
</div>
|
||||
) : (
|
||||
(['built_in', 'service'] as const).map((group) => {
|
||||
const tools = availableTools.filter(
|
||||
(tool) => (tool.group ?? 'built_in') === group,
|
||||
);
|
||||
if (tools.length === 0) return null;
|
||||
return (
|
||||
<section key={group} className="flex flex-col gap-3">
|
||||
<>
|
||||
{services.length > 0 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="sm"
|
||||
title={
|
||||
group === 'built_in'
|
||||
? t('settings.tools.groupBuiltIn')
|
||||
: t('settings.tools.groupService')
|
||||
}
|
||||
title={t('settings.tools.groupService')}
|
||||
/>
|
||||
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tools.map((tool) => (
|
||||
{services.map((connector) => (
|
||||
<Card
|
||||
asChild
|
||||
key={connector.key}
|
||||
variant="outline"
|
||||
padding="lg"
|
||||
interactive
|
||||
className="h-44 w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openService(connector)}
|
||||
data-testid={`add-tool-service-${connector.key}`}
|
||||
>
|
||||
<ConnectorIcon
|
||||
icon={connector.icon}
|
||||
className="size-6"
|
||||
/>
|
||||
<CardTitle
|
||||
title={connectorName(t, connector)}
|
||||
className="truncate"
|
||||
>
|
||||
{connectorName(t, connector)}
|
||||
</CardTitle>
|
||||
<CardDescription size="xs" className="line-clamp-2">
|
||||
{connectorDescription(t, connector)}
|
||||
</CardDescription>
|
||||
<CardFooter>
|
||||
<ConnectorStateBadge connector={connector} />
|
||||
</CardFooter>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{builtIn.length > 0 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="sm"
|
||||
title={t('settings.tools.groupBuiltIn')}
|
||||
/>
|
||||
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{builtIn.map((tool) => (
|
||||
<Card
|
||||
asChild
|
||||
key={tool.name}
|
||||
@@ -225,8 +294,8 @@ export default function AddToolModal({
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -19,9 +19,11 @@ vi.mock('../modals/AddActionModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ImportSpecModal', () => ({ default: () => null }));
|
||||
|
||||
const updateTool = vi.fn();
|
||||
const createTool = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
updateTool: (...args: unknown[]) => updateTool(...args),
|
||||
createTool: (...args: unknown[]) => createTool(...args),
|
||||
deleteTool: () => Promise.resolve(),
|
||||
},
|
||||
}));
|
||||
@@ -393,4 +395,14 @@ describe('ToolConfig', () => {
|
||||
expect(cancel?.dataset.size).toBe('sm');
|
||||
expect(cancel?.dataset.shape).toBe('pill');
|
||||
});
|
||||
|
||||
it('creates a draft OpenAPI tool on its first save, not before', async () => {
|
||||
createTool.mockResolvedValue({ ok: true });
|
||||
updateTool.mockClear();
|
||||
await render({ ...apiTool, id: '' } as APIToolType);
|
||||
expect(createTool).not.toHaveBeenCalled();
|
||||
await act(async () => buttonByText('settings.tools.save')!.click());
|
||||
expect(updateTool).not.toHaveBeenCalled();
|
||||
expect(createTool.mock.calls[0][0]).toMatchObject({ name: 'api_tool' });
|
||||
});
|
||||
});
|
||||
@@ -245,6 +245,26 @@ export default function ToolConfig({
|
||||
});
|
||||
};
|
||||
|
||||
// Saves the tool; a draft without an id (a new OpenAPI tool) is created
|
||||
// on its first save, so leaving without saving leaves nothing behind.
|
||||
const persistTool = async (configToSave: Record<string, unknown>) => {
|
||||
const payload = {
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
};
|
||||
if (tool.id) {
|
||||
await userService.updateTool({ id: tool.id, ...payload }, token);
|
||||
return;
|
||||
}
|
||||
const response = await userService.createTool(payload, token);
|
||||
if (!response.ok) throw new Error('create failed');
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!validateConfig()) return;
|
||||
const configToSave = buildConfigToSave();
|
||||
@@ -253,19 +273,7 @@ export default function ToolConfig({
|
||||
setSaveError('');
|
||||
|
||||
try {
|
||||
await userService.updateTool(
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await persistTool(configToSave);
|
||||
setInitialState({
|
||||
customName,
|
||||
configValues: { ...configValues },
|
||||
@@ -282,6 +290,11 @@ export default function ToolConfig({
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
// A draft (a new OpenAPI tool not saved yet) has nothing to delete.
|
||||
if (!tool.id) {
|
||||
handleGoBack();
|
||||
return;
|
||||
}
|
||||
userService.deleteTool({ id: tool.id }, token).then(() => {
|
||||
handleGoBack();
|
||||
});
|
||||
@@ -362,7 +375,8 @@ export default function ToolConfig({
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={!hasUnsavedChanges}
|
||||
// A draft (no id yet) is saved to create it.
|
||||
disabled={!hasUnsavedChanges && !!tool.id}
|
||||
loading={saving}
|
||||
>
|
||||
{t('settings.tools.save')}
|
||||
@@ -768,19 +782,7 @@ export default function ToolConfig({
|
||||
setSaveError('');
|
||||
|
||||
try {
|
||||
await userService.updateTool(
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await persistTool(configToSave);
|
||||
setShowUnsavedModal(false);
|
||||
handleGoBack();
|
||||
} catch {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
selectConnectorCatalog,
|
||||
} from '../connectors/connectorsSlice';
|
||||
import { useLoaderState } from '../hooks';
|
||||
import type { AvailableToolType } from '../modals/types';
|
||||
import AddToolModal from '../modals/AddToolModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import MCPServerModal from '../modals/MCPServerModal';
|
||||
@@ -283,15 +284,43 @@ export default function Tools() {
|
||||
dispatch(loadConnectors({ token }));
|
||||
}, []);
|
||||
|
||||
// The Connectors page creates an OpenAPI tool and sends its id here so
|
||||
// the spec import opens straight away.
|
||||
const openToolId = (location.state as { openToolId?: string } | null)
|
||||
?.openToolId;
|
||||
// The Connectors page opens a new OpenAPI tool here as an unsaved draft:
|
||||
// the spec import opens straight away and the tool is created on save.
|
||||
const routeState = location.state as {
|
||||
openToolId?: string;
|
||||
newApiTool?: boolean;
|
||||
} | null;
|
||||
const openToolId = routeState?.openToolId;
|
||||
const newApiTool = routeState?.newApiTool;
|
||||
React.useEffect(() => {
|
||||
if (!openToolId) return;
|
||||
handleToolAdded(openToolId);
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}, [openToolId]);
|
||||
React.useEffect(() => {
|
||||
if (!newApiTool) return;
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
userService
|
||||
.getAvailableTools(token)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const template = (data.data as AvailableToolType[] | undefined)?.find(
|
||||
(candidate) => candidate.name === 'api_tool',
|
||||
);
|
||||
if (!template) return;
|
||||
setSelectedTool({
|
||||
id: '',
|
||||
name: template.name,
|
||||
displayName: template.displayName,
|
||||
customName: '',
|
||||
description: template.description,
|
||||
config: {},
|
||||
actions: template.actions,
|
||||
status: true,
|
||||
} as unknown as UserToolType);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [newApiTool]);
|
||||
return (
|
||||
<div>
|
||||
{selectedTool ? (
|
||||
|
||||
@@ -38,6 +38,11 @@ vi.mock('../api/services/userService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const launch = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../connectors/useConnectorLauncher', () => ({
|
||||
default: () => ({ launch, modals: null }),
|
||||
}));
|
||||
|
||||
import Upload from './Upload';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -190,6 +195,34 @@ describe('Upload source-type tiles', () => {
|
||||
connectorsState.connections = [];
|
||||
});
|
||||
|
||||
it('hands a connection tile over to the connect wizard', async () => {
|
||||
launch.mockClear();
|
||||
const s3 = {
|
||||
key: 's3',
|
||||
icon: 's3',
|
||||
sync_ingestor: 's3',
|
||||
auth_kind: 'api_key',
|
||||
available: true,
|
||||
missing_settings: [],
|
||||
};
|
||||
connectorsState.catalog = [s3];
|
||||
connectorsState.connections = [
|
||||
{ id: 'k1', connector_key: 's3', status: 'connected' },
|
||||
];
|
||||
await render();
|
||||
const tile = tiles().find((t) =>
|
||||
t.textContent?.includes('ingestors.s3.label'),
|
||||
)!;
|
||||
await act(async () => tile.click());
|
||||
// An existing account goes straight to choosing what to sync.
|
||||
expect(launch).toHaveBeenCalledWith(s3, {
|
||||
mode: 'sync',
|
||||
connectionId: 'k1',
|
||||
});
|
||||
connectorsState.catalog = [];
|
||||
connectorsState.connections = [];
|
||||
});
|
||||
|
||||
it('picks the saved account once connections finish loading', async () => {
|
||||
connectorsState.catalog = [
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ import { OptionCard } from '../components/ui/option-card';
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import ConnectorSetupNotice from '../connectors/ConnectorSetupNotice';
|
||||
import useConnectorLauncher from '../connectors/useConnectorLauncher';
|
||||
import { formatCount } from '../utils/dateTimeUtils';
|
||||
import {
|
||||
loadConnectors,
|
||||
@@ -109,6 +110,12 @@ function Upload({
|
||||
const connections = useSelector(selectConnections);
|
||||
const connectorsLoaded = useSelector(selectConnectorsLoaded);
|
||||
const connectorsEnabled = useSelector(selectConnectorsEnabled);
|
||||
// A connection tile hands over to the connect wizard, the one flow every
|
||||
// entry point uses; this modal steps aside and closes with it.
|
||||
const [handedOver, setHandedOver] = useState(false);
|
||||
const { launch, modals: connectModals } = useConnectorLauncher({
|
||||
onConnected: () => close(),
|
||||
});
|
||||
|
||||
const [files, setfiles] = useState<File[]>(receivedFile);
|
||||
// Names of the files the last drop turned away (over the size limit or of
|
||||
@@ -1263,7 +1270,24 @@ function Upload({
|
||||
`modals.uploadDoc.ingestors.${option.value}.label`,
|
||||
)}
|
||||
description={connectionTileState(option.value)}
|
||||
onClick={() => handleIngestorTypeChange(option.value)}
|
||||
onClick={() => {
|
||||
if (connectorsEnabled && connector?.available) {
|
||||
const account = connections.find(
|
||||
(c) =>
|
||||
c.connector_key === connector.key &&
|
||||
c.status === 'connected',
|
||||
);
|
||||
setHandedOver(true);
|
||||
launch(
|
||||
connector,
|
||||
account
|
||||
? { mode: 'sync', connectionId: account.id }
|
||||
: {},
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleIngestorTypeChange(option.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1278,6 +1302,8 @@ function Upload({
|
||||
selectedConnector && needsSetup ? (
|
||||
<ConnectorSetupNotice connector={selectedConnector} />
|
||||
) : null;
|
||||
if (handedOver) return <>{connectModals}</>;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={true}
|
||||
|
||||
@@ -221,3 +221,16 @@ class TestAvailableTools:
|
||||
names = {t["name"] for t in AvailableTools().get().get_json()["data"]}
|
||||
assert "telegram" not in names
|
||||
assert "brave" in names
|
||||
|
||||
def test_service_tools_use_the_connector_name(self, app, pg_conn):
|
||||
"""One name everywhere: "Telegram", not the tool's "Telegram Bot"."""
|
||||
from docsgpt.api.user.tools.routes import AvailableTools
|
||||
|
||||
with _db(pg_conn), app.test_request_context("/api/available_tools"):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": "alice"}
|
||||
tools = {t["name"]: t for t in AvailableTools().get().get_json()["data"]}
|
||||
assert tools["telegram"]["displayName"] == "Telegram"
|
||||
assert tools["ntfy"]["displayName"] == "ntfy"
|
||||
assert tools["postgres"]["displayName"] == "PostgreSQL"
|
||||
Reference in new issue
Block a user