mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 06:17:23 +00:00
Show which service did what, and let owners pick whose account a share uses
- Tool chips and the streaming status name the service: "Searched Notion", "Used Linear: create issue". Citations from a connector source read "From Google Drive". - The share dialog for a connection-backed tool asks whose account team members use. Owner mode warns which account they act as and needs an explicit OK when the tool can write; an admin-forced mode is locked. - Admin > Connectors: enable or disable each connector, force a sharing mode, allow custom MCP servers, and a setup guide per OAuth connector with its redirect URI and the server settings still missing. Warns when credentials use the default encryption key. - The composer and the agent builder group tools by connection, and the Sources and Tools popovers link to Connect more. - MCP presets open the server form titled after the service.
This commit is contained in:
1 parent
cefb117277
commit
2411118048
32 files changed
+1427
-84
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const getAdmin = vi.fn();
|
||||
const updateAdmin = vi.fn();
|
||||
vi.mock('../api/services/connectorsService', () => ({
|
||||
default: {
|
||||
getAdmin: (...args: unknown[]) => getAdmin(...args),
|
||||
updateAdmin: (...args: unknown[]) => updateAdmin(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { prefSlice } from '../preferences/preferenceSlice';
|
||||
import Connectors from './Connectors';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const connector = (overrides: Record<string, unknown> = {}) => ({
|
||||
key: 'google_drive',
|
||||
name: 'Google Drive',
|
||||
icon: 'google-drive',
|
||||
publisher: 'built_in',
|
||||
auth_kind: 'oauth',
|
||||
enabled: true,
|
||||
credential_mode: 'choose',
|
||||
configured: false,
|
||||
required_settings: [
|
||||
{ name: 'GOOGLE_CLIENT_ID', set: true },
|
||||
{ name: 'GOOGLE_CLIENT_SECRET', set: false },
|
||||
],
|
||||
connection_count: 3,
|
||||
docs_url: null,
|
||||
mcp_url: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const payload = (overrides: Record<string, unknown> = {}) => ({
|
||||
success: true,
|
||||
connectors: [
|
||||
connector(),
|
||||
connector({
|
||||
key: 'mcp_notion',
|
||||
name: 'Notion',
|
||||
icon: 'notion',
|
||||
publisher: 'preset',
|
||||
auth_kind: 'mcp_oauth',
|
||||
configured: true,
|
||||
required_settings: [],
|
||||
connection_count: 0,
|
||||
}),
|
||||
],
|
||||
allow_custom_mcp: true,
|
||||
default_encryption_key: false,
|
||||
oauth_redirect_uri: 'https://docs.example/api/connectors/callback',
|
||||
mcp_redirect_uri: 'https://docs.example/api/mcp_server/callback',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Admin Connectors', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
getAdmin.mockReset();
|
||||
updateAdmin.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
const store = configureStore({
|
||||
reducer: { preference: prefSlice.reducer },
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<Connectors />
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
it('lists each connector with its setup state and redirect URIs', async () => {
|
||||
getAdmin.mockResolvedValue(payload());
|
||||
await render();
|
||||
const rows = Array.from(container.querySelectorAll('tbody tr'));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('Google Drive');
|
||||
expect(rows[0].textContent).toContain('Needs setup');
|
||||
expect(rows[0].textContent).toContain('Setup guide');
|
||||
expect(rows[1].textContent).toContain('Preset');
|
||||
expect(rows[1].textContent).toContain('Ready');
|
||||
expect(rows[1].textContent).not.toContain('Setup guide');
|
||||
expect(container.textContent).toContain(
|
||||
'https://docs.example/api/connectors/callback',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when credentials use the default encryption key', async () => {
|
||||
getAdmin.mockResolvedValue(payload({ default_encryption_key: true }));
|
||||
await render();
|
||||
expect(container.textContent).toContain('admin.connectors.defaultKey');
|
||||
expect(container.textContent).toContain('docsgpt connectors reencrypt');
|
||||
});
|
||||
|
||||
it('saves a connector toggle as a policy', async () => {
|
||||
getAdmin.mockResolvedValue(payload());
|
||||
updateAdmin.mockResolvedValue(
|
||||
payload({
|
||||
connectors: [connector({ enabled: false })],
|
||||
}),
|
||||
);
|
||||
await render();
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Google Drive enabled"]',
|
||||
)!;
|
||||
await act(async () => toggle.click());
|
||||
expect(updateAdmin).toHaveBeenCalledWith(
|
||||
{ policies: { google_drive: { enabled: false } } },
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
container
|
||||
.querySelector('[aria-label="Google Drive enabled"]')
|
||||
?.getAttribute('aria-checked'),
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('shows an error and keeps the page when a save fails', async () => {
|
||||
getAdmin.mockResolvedValue(payload());
|
||||
updateAdmin.mockResolvedValue({ success: false });
|
||||
await render();
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLButtonElement>('#allow-custom-mcp')!.click(),
|
||||
);
|
||||
expect(updateAdmin).toHaveBeenCalledWith({ allow_custom_mcp: false }, null);
|
||||
expect(container.textContent).toContain('Could not save the change.');
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('offers a retry when loading fails', async () => {
|
||||
getAdmin.mockRejectedValue(new Error('offline'));
|
||||
await render();
|
||||
expect(container.textContent).toContain('Failed to load connectors.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { ExternalLink, TriangleAlert } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import connectorsService from '../api/services/connectorsService';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { LoadingState } from '../components/ui/loading-state';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { SettingRow, SettingRows } from '../components/ui/setting-row';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { LoadError, fmtNumber } from './AdminUI';
|
||||
|
||||
type Policy = 'choose' | 'owner' | 'member';
|
||||
|
||||
type AdminConnector = {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
publisher: 'built_in' | 'preset' | 'custom';
|
||||
auth_kind: string;
|
||||
enabled: boolean;
|
||||
credential_mode: Policy;
|
||||
configured: boolean;
|
||||
required_settings: { name: string; set: boolean }[];
|
||||
connection_count: number;
|
||||
docs_url: string | null;
|
||||
mcp_url: string | null;
|
||||
};
|
||||
|
||||
type AdminConnectorsData = {
|
||||
success: boolean;
|
||||
connectors: AdminConnector[];
|
||||
allow_custom_mcp: boolean;
|
||||
default_encryption_key: boolean;
|
||||
oauth_redirect_uri: string;
|
||||
mcp_redirect_uri: string;
|
||||
};
|
||||
|
||||
const POLICY_LABELS: Record<Policy, string> = {
|
||||
choose: 'Let owners choose',
|
||||
member: "Always each member's own account",
|
||||
owner: "Always the owner's account",
|
||||
};
|
||||
|
||||
function CodeRow({ value }: { value: string }) {
|
||||
return (
|
||||
<Card variant="filled" padding="sm" className="flex-row items-start gap-2">
|
||||
<pre className="min-w-0 flex-1 font-mono text-xs wrap-anywhere whitespace-pre-wrap">
|
||||
{value}
|
||||
</pre>
|
||||
<CopyButton textToCopy={value} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupGuide({
|
||||
connector,
|
||||
redirectUri,
|
||||
onClose,
|
||||
}: {
|
||||
connector: AdminConnector;
|
||||
redirectUri: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={`Set up ${connector.name}`}
|
||||
description="Register DocsGPT as an OAuth app with the provider, then set these server settings and restart the API and the worker."
|
||||
footer={
|
||||
<Button size="lg" shape="pill" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionHeader as="h3" size="xs" title="Redirect URI to register" />
|
||||
<CodeRow value={redirectUri} />
|
||||
</section>
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionHeader as="h3" size="xs" title="Server settings" />
|
||||
<ul className="flex flex-col gap-2">
|
||||
{connector.required_settings.map((setting) => (
|
||||
<li
|
||||
key={setting.name}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<code className="font-mono text-xs">{setting.name}</code>
|
||||
<Badge variant={setting.set ? 'success' : 'warning'}>
|
||||
{setting.set ? 'Set' : 'Missing'}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
{connector.key === 'google_drive' && (
|
||||
<Alert variant="info" role="note">
|
||||
<TriangleAlert />
|
||||
<AlertDescription>
|
||||
Publish the Google OAuth app (or use an internal Workspace app).
|
||||
Apps left in Testing get refresh tokens that expire after seven
|
||||
days, which stops background sync.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{connector.docs_url && (
|
||||
<Button variant="link" size="inline" asChild className="w-fit">
|
||||
<a
|
||||
href={connector.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Setup guide
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin > Connectors: which connectors members may use, whose account a
|
||||
* shared tool runs with, and what each OAuth connector still needs from the
|
||||
* server. English only, like the rest of the admin pages.
|
||||
*/
|
||||
export default function Connectors() {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const [data, setData] = useState<AdminConnectorsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const [guide, setGuide] = useState<AdminConnector | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await connectorsService.getAdmin(token));
|
||||
} catch {
|
||||
setData({ success: false } as AdminConnectorsData);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const save = async (body: {
|
||||
policies?: Record<string, { enabled?: boolean; credential_mode?: Policy }>;
|
||||
allow_custom_mcp?: boolean;
|
||||
}) => {
|
||||
setSaveError(false);
|
||||
try {
|
||||
const next = await connectorsService.updateAdmin(body, token);
|
||||
if (next?.success) setData(next);
|
||||
else setSaveError(true);
|
||||
} catch {
|
||||
setSaveError(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (data === null && loading) return <LoadingState fill="block" />;
|
||||
if (!data?.success)
|
||||
return <LoadError message="Failed to load connectors." onRetry={load} />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Choose which connectors members can use and whose account a shared tool
|
||||
runs with. Connectors that need server settings stay listed for members
|
||||
as Needs admin setup until you add them.
|
||||
</p>
|
||||
|
||||
{data.default_encryption_key && (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>{t('admin.connectors.defaultKey')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Stored credentials are encrypted with ENCRYPTION_SECRET_KEY, which
|
||||
still has its public default value. Set your own, keep the old one
|
||||
in ENCRYPTION_SECRET_KEY_PREVIOUS and run{' '}
|
||||
<code className="font-mono text-xs">
|
||||
docsgpt connectors reencrypt
|
||||
</code>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{saveError && (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlert />
|
||||
<AlertDescription>Could not save the change.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label="Allow custom MCP servers"
|
||||
description="Members can connect any remote MCP server. Presets are controlled one by one below."
|
||||
htmlFor="allow-custom-mcp"
|
||||
>
|
||||
<Switch
|
||||
id="allow-custom-mcp"
|
||||
checked={data.allow_custom_mcp}
|
||||
onCheckedChange={(checked) =>
|
||||
save({ allow_custom_mcp: checked === true })
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingRows>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader title="Redirect URIs" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Register these with each provider's OAuth app.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-3 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Google Drive, SharePoint and Confluence
|
||||
</span>
|
||||
<CodeRow value={data.oauth_redirect_uri} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">MCP servers</span>
|
||||
<CodeRow value={data.mcp_redirect_uri} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader title="Connectors" />
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Connector</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader align="right">Connections</TableHeader>
|
||||
<TableHeader>Enabled</TableHeader>
|
||||
<TableHeader>Shared tools use</TableHeader>
|
||||
<TableHeader />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.connectors.map((connector) => (
|
||||
<TableRow key={connector.key}>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-2">
|
||||
<ConnectorIcon
|
||||
icon={connector.icon}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{connector.name}</span>
|
||||
{connector.publisher !== 'built_in' && (
|
||||
<Badge variant="neutral">
|
||||
{connector.publisher === 'preset'
|
||||
? 'Preset'
|
||||
: 'Custom'}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={connector.configured ? 'success' : 'warning'}
|
||||
>
|
||||
{connector.configured ? 'Ready' : 'Needs setup'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell align="right" className="tabular-nums">
|
||||
{fmtNumber(connector.connection_count)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={connector.enabled}
|
||||
aria-label={`${connector.name} enabled`}
|
||||
onCheckedChange={(checked) =>
|
||||
save({
|
||||
policies: {
|
||||
[connector.key]: { enabled: checked === true },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={connector.credential_mode}
|
||||
onValueChange={(value) =>
|
||||
save({
|
||||
policies: {
|
||||
[connector.key]: {
|
||||
credential_mode: value as Policy,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-60"
|
||||
aria-label={`${connector.name} sharing policy`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(POLICY_LABELS) as Policy[]).map(
|
||||
(policy) => (
|
||||
<SelectItem key={policy} value={policy}>
|
||||
{POLICY_LABELS[policy]}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{connector.required_settings.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setGuide(connector)}
|
||||
>
|
||||
Setup guide
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</section>
|
||||
|
||||
{guide && (
|
||||
<SetupGuide
|
||||
connector={guide}
|
||||
redirectUri={data.oauth_redirect_uri}
|
||||
onClose={() => setGuide(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import Admins from './Admins';
|
||||
import Activity from './Activity';
|
||||
import Overview from './Overview';
|
||||
import Quotas from './Quotas';
|
||||
import Connectors from './Connectors';
|
||||
import Usage from './Usage';
|
||||
import Users from './Users';
|
||||
|
||||
@@ -39,6 +40,7 @@ export default function Admin() {
|
||||
<Route path="roles" element={<Admins />} />
|
||||
<Route path="usage" element={<Usage />} />
|
||||
<Route path="quotas" element={<Quotas />} />
|
||||
<Route path="connectors" element={<Connectors />} />
|
||||
<Route path="audit" element={<Activity />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -57,6 +57,12 @@ vi.mock('../api/services/devicesService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/connectorsService', () => ({
|
||||
default: {
|
||||
listConnections: () => Promise.resolve({ connections: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/modelService', () => ({
|
||||
default: {
|
||||
getModels: () => jsonResponse({ models: [] }),
|
||||
|
||||
@@ -45,6 +45,9 @@ import {
|
||||
} from '../components/MultiSelectPopover';
|
||||
import SourcesPopoverFooter from '../components/SourcesPopoverFooter';
|
||||
import ToolIcon from '../components/ToolIcon';
|
||||
import connectorsService from '../api/services/connectorsService';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import type { Connection } from '../connectors/types';
|
||||
import AgentDetailsModal from '../modals/AgentDetailsModal';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
@@ -552,12 +555,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
|
||||
useEffect(() => {
|
||||
const getTools = async () => {
|
||||
const [toolsResponse, devicesResult] = await Promise.all([
|
||||
userService.getUserTools(token),
|
||||
// Tolerate failures here: the picker should still render the
|
||||
// tool list even if /api/devices returns an error or 401.
|
||||
devicesService.list(token).catch(() => ({ devices: [] })),
|
||||
]);
|
||||
const [toolsResponse, devicesResult, connectionsResult] =
|
||||
await Promise.all([
|
||||
userService.getUserTools(token),
|
||||
// Tolerate failures here: the picker should still render the
|
||||
// tool list even if /api/devices returns an error or 401.
|
||||
devicesService.list(token).catch(() => ({ devices: [] })),
|
||||
connectorsService
|
||||
.listConnections(token)
|
||||
.catch(() => ({ connections: [] })),
|
||||
]);
|
||||
const connectionsById = new Map<string, Connection>(
|
||||
((connectionsResult?.connections ?? []) as Connection[]).map((c) => [
|
||||
c.id,
|
||||
c,
|
||||
]),
|
||||
);
|
||||
if (!toolsResponse.ok) throw new Error('Failed to fetch tools');
|
||||
const data = await toolsResponse.json();
|
||||
// Hide workflow-only builtins (e.g. read_document) from the classic
|
||||
@@ -576,19 +589,39 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
!Number.isNaN(seen) && Date.now() - seen < onlineWindowMs;
|
||||
devicesById.set(d.id, { online, last_seen_at: d.last_seen_at });
|
||||
});
|
||||
// Group ordering: builtins -> defaults -> user tools (sorted via the
|
||||
// MultiSelectPopover first-appearance grouping).
|
||||
// Group ordering: builtins -> defaults -> one group per connection
|
||||
// (the service and its account) -> custom tools, via the
|
||||
// MultiSelectPopover first-appearance grouping.
|
||||
const connectionOf = (tool: UserToolType) =>
|
||||
tool.connection_id
|
||||
? connectionsById.get(tool.connection_id)
|
||||
: undefined;
|
||||
const rank = (tool: UserToolType) =>
|
||||
tool.builtin ? 0 : tool.default ? 1 : connectionOf(tool) ? 2 : 3;
|
||||
const groupFor = (tool: UserToolType): string => {
|
||||
if (tool.builtin) return t('agents.form.toolsPopup.groupBuiltin');
|
||||
if (tool.default) return t('agents.form.toolsPopup.groupDefault');
|
||||
const connection = connectionOf(tool);
|
||||
if (connection)
|
||||
return t('agents.form.toolsPopup.groupConnection', {
|
||||
name: connection.name,
|
||||
account: connection.account_label,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
return t('agents.form.toolsPopup.groupCustom');
|
||||
};
|
||||
const tools: MultiSelectPopoverItem[] = visibleTools.map(
|
||||
(tool: UserToolType) => {
|
||||
const tools: MultiSelectPopoverItem[] = [...visibleTools]
|
||||
.sort((a, b) => rank(a) - rank(b))
|
||||
.map((tool: UserToolType) => {
|
||||
const connection = connectionOf(tool);
|
||||
const base: MultiSelectPopoverItem = {
|
||||
id: tool.id,
|
||||
label: getToolDisplayName(tool),
|
||||
icon: <ToolIcon name={tool.name} className="size-5" />,
|
||||
icon: connection ? (
|
||||
<ConnectorIcon icon={connection.icon} className="size-5" />
|
||||
) : (
|
||||
<ToolIcon name={tool.name} className="size-5" />
|
||||
),
|
||||
group: groupFor(tool),
|
||||
};
|
||||
if (tool.name === 'remote_device') {
|
||||
@@ -607,8 +640,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
);
|
||||
}
|
||||
return base;
|
||||
},
|
||||
);
|
||||
});
|
||||
const groupOrder = [
|
||||
t('agents.form.toolsPopup.groupBuiltin'),
|
||||
t('agents.form.toolsPopup.groupDefault'),
|
||||
|
||||
@@ -105,6 +105,9 @@ const endpoints = {
|
||||
CONNECTION_TOOL_PERMISSIONS: (id: string, toolId: string) =>
|
||||
`/api/connections/${encodeURIComponent(id)}/tools/${encodeURIComponent(toolId)}/permissions`,
|
||||
CONNECTIONS_CLAIM: '/api/connections/claim',
|
||||
TOOL_CREDENTIAL_MODE: (toolId: string) =>
|
||||
`/api/connections/tools/${encodeURIComponent(toolId)}/credential-mode`,
|
||||
ADMIN_CONNECTORS: '/api/admin/connectors',
|
||||
GET_CHUNKS: (
|
||||
docId: string,
|
||||
page: number,
|
||||
|
||||
@@ -107,6 +107,22 @@ const connectorsService = {
|
||||
token,
|
||||
),
|
||||
),
|
||||
setCredentialMode: async (
|
||||
toolId: string,
|
||||
mode: 'owner' | 'member',
|
||||
token: string | null,
|
||||
) =>
|
||||
json(
|
||||
await apiClient.put(
|
||||
endpoints.USER.TOOL_CREDENTIAL_MODE(toolId),
|
||||
{ mode },
|
||||
token,
|
||||
),
|
||||
),
|
||||
getAdmin: async (token: string | null) =>
|
||||
json(await apiClient.get(endpoints.USER.ADMIN_CONNECTORS, token)),
|
||||
updateAdmin: async (body: Record<string, unknown>, token: string | null) =>
|
||||
json(await apiClient.put(endpoints.USER.ADMIN_CONNECTORS, body, token)),
|
||||
claim: async (provider: string, sessionToken: string, token: string | null) =>
|
||||
json(
|
||||
await apiClient.post(
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('react-i18next', () => ({
|
||||
// The upload modal pulls in the whole ingest UI; the composer never opens it here.
|
||||
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
||||
|
||||
import connectorsReducer from '../connectors/connectorsSlice';
|
||||
import notificationsReducer from '../notifications/notificationsSlice';
|
||||
import { prefSlice } from '../preferences/preferenceSlice';
|
||||
import type { RootState } from '../store';
|
||||
@@ -28,6 +29,7 @@ const makeStore = () =>
|
||||
preference: prefSlice.reducer,
|
||||
upload: uploadReducer,
|
||||
notifications: notificationsReducer,
|
||||
connectors: connectorsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -35,13 +35,18 @@ import {
|
||||
selectToken,
|
||||
setSelectedDocs,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import type { RootState } from '../store';
|
||||
import type { AppDispatch, RootState } from '../store';
|
||||
import Upload from '../upload/Upload';
|
||||
import { isTouchDevice } from '../utils/browserUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { IconButton } from './ui/icon-button';
|
||||
import { type MultiSelectPopoverItem } from './MultiSelectPopover';
|
||||
import ToolIcon from './ToolIcon';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import {
|
||||
loadConnectors,
|
||||
selectConnections,
|
||||
} from '../connectors/connectorsSlice';
|
||||
import {
|
||||
AttachFileButton,
|
||||
AttachmentChipList,
|
||||
@@ -338,6 +343,7 @@ export default function MessageInput({
|
||||
const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false);
|
||||
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
|
||||
const [userTools, setUserTools] = useState<UserToolType[]>([]);
|
||||
const connections = useSelector(selectConnections);
|
||||
const [toolsLoading, setToolsLoading] = useState(false);
|
||||
const [uploadModalState, setUploadModalState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
@@ -1561,14 +1567,39 @@ export default function MessageInput({
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isToolsPopupOpen) fetchUserTools();
|
||||
if (isToolsPopupOpen) {
|
||||
fetchUserTools();
|
||||
(dispatch as AppDispatch)(loadConnectors({ token }));
|
||||
}
|
||||
}, [isToolsPopupOpen, fetchUserTools]);
|
||||
|
||||
const toolItems: MultiSelectPopoverItem[] = userTools.map((tool) => ({
|
||||
id: tool.id,
|
||||
label: tool.customName || tool.displayName,
|
||||
icon: <ToolIcon name={tool.name} className="size-5" />,
|
||||
}));
|
||||
// Tools from a connected service sit under that service; the rest are
|
||||
// built in. Groups only appear once some tool comes from a connection.
|
||||
const toolConnection = (tool: UserToolType) =>
|
||||
tool.connection_id
|
||||
? connections.find((c) => c.id === tool.connection_id)
|
||||
: undefined;
|
||||
const anyConnectedTool = userTools.some((tool) => toolConnection(tool));
|
||||
const toolItems: MultiSelectPopoverItem[] = [...userTools]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(Boolean(toolConnection(a))) - Number(Boolean(toolConnection(b))),
|
||||
)
|
||||
.map((tool) => {
|
||||
const connection = toolConnection(tool);
|
||||
return {
|
||||
id: tool.id,
|
||||
label: tool.customName || tool.displayName,
|
||||
icon: connection ? (
|
||||
<ConnectorIcon icon={connection.icon} className="size-5" />
|
||||
) : (
|
||||
<ToolIcon name={tool.name} className="size-5" />
|
||||
),
|
||||
group: anyConnectedTool
|
||||
? (connection?.name ?? t('settings.tools.groupBuiltIn'))
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedToolIds = userTools
|
||||
.filter((tool) => tool.status)
|
||||
|
||||
@@ -53,8 +53,11 @@ describe('SourcesPopoverFooter', () => {
|
||||
|
||||
it('renders the sources link as an inline link Button', async () => {
|
||||
await renderFooter();
|
||||
const link = container.querySelector('a')!;
|
||||
const [link, connect] = Array.from(container.querySelectorAll('a'));
|
||||
expect(link.getAttribute('href')).toBe('/settings/sources');
|
||||
expect(connect.getAttribute('href')).toBe(
|
||||
'/settings/connectors?capability=sync',
|
||||
);
|
||||
expect(link.dataset.slot).toBe('button');
|
||||
expect(link.className).toContain('text-primary');
|
||||
expect(link.className).toContain('text-sm');
|
||||
|
||||
@@ -10,7 +10,10 @@ type SourcesPopoverFooterProps = {
|
||||
onUploadClick: () => void;
|
||||
};
|
||||
|
||||
/** Shared footer for source pickers: a link to the sources page and an upload shortcut. */
|
||||
/**
|
||||
* Shared footer for source pickers: links to the sources page and to the
|
||||
* connectors that can sync content, and an upload shortcut.
|
||||
*/
|
||||
export default function SourcesPopoverFooter({
|
||||
onNavigate,
|
||||
onUploadClick,
|
||||
@@ -21,12 +24,20 @@ export default function SourcesPopoverFooter({
|
||||
// One row when it fits (link left, upload right); on a narrow sheet or a
|
||||
// long locale the button wraps under the link.
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<Link to="/settings/sources" onClick={onNavigate}>
|
||||
{t('settings.sources.goToSources')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<Link to="/settings/sources" onClick={onNavigate}>
|
||||
{t('settings.sources.goToSources')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<Link to="/settings/connectors?capability=sync" onClick={onNavigate}>
|
||||
{t('conversation.sources.connectMore')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
|
||||
@@ -38,12 +38,20 @@ export default function ToolsTrigger({
|
||||
emptyMessage={t('settings.tools.noToolsFound')}
|
||||
loading={loading}
|
||||
footer={
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<a href="/settings/tools">
|
||||
{t('settings.tools.manageTools')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<a href="/settings/tools">
|
||||
{t('settings.tools.manageTools')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<a href="/settings/connectors?capability=tools">
|
||||
{t('conversation.sources.connectMore')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
|
||||
@@ -27,3 +27,27 @@ export const connectorName = (
|
||||
defaultValue: connector.name,
|
||||
})
|
||||
: connector.name;
|
||||
|
||||
const BUILT_IN_ICONS: Record<string, string> = {
|
||||
google_drive: 'drive',
|
||||
share_point: 'sharepoint',
|
||||
confluence: 'confluence',
|
||||
s3: 's3',
|
||||
reddit: 'reddit',
|
||||
brave: 'tool_brave',
|
||||
telegram: 'tool_telegram',
|
||||
ntfy: 'tool_ntfy',
|
||||
postgres: 'tool_postgres',
|
||||
};
|
||||
|
||||
/**
|
||||
* The catalog icon for a connector key, without the catalog: chat chips and
|
||||
* citations render before (or without) the Connectors data. Presets are
|
||||
* `mcp:<name>` with a `<name>` logo; a custom server falls back to a plug.
|
||||
*/
|
||||
export const connectorIconKey = (key: string | null | undefined) => {
|
||||
if (!key) return 'plug';
|
||||
if (BUILT_IN_ICONS[key]) return BUILT_IN_ICONS[key];
|
||||
if (key.startsWith('mcp:')) return key.slice(4);
|
||||
return 'plug';
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SchedulerToolCallCard from '../agents/schedules/SchedulerToolCallCard';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import { connectorIconKey } from '../connectors/i18n';
|
||||
import ToolIcon from '../components/ToolIcon';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { usePacedText } from '../hooks';
|
||||
@@ -270,10 +272,17 @@ function InlineToolCallChip({
|
||||
isLive ? 'animate-pulse' : '',
|
||||
)}
|
||||
>
|
||||
<ToolIcon
|
||||
name={toolCall.tool_name}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
{toolCall.connector_key ? (
|
||||
<ConnectorIcon
|
||||
icon={connectorIconKey(toolCall.connector_key)}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
) : (
|
||||
<ToolIcon
|
||||
name={toolCall.tool_name}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
)}
|
||||
<span className="bg-muted-foreground/50 hidden size-1.5 rounded-full only:block" />
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
import { isToolCallRunning } from '../utils/streamingStatusUtils';
|
||||
import AnswerFlow from './AnswerFlow';
|
||||
import ConnectToolCallBar from './ConnectToolCallBar';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import { connectorIconKey } from '../connectors/i18n';
|
||||
import { AnswerSegment } from './answerSegments';
|
||||
import { deriveArtifactChips } from './artifactChips';
|
||||
import { FEEDBACK, MESSAGE_TYPE, ResearchState } from './conversationModels';
|
||||
@@ -66,7 +68,13 @@ const ConversationBubble = forwardRef<
|
||||
feedback?: FEEDBACK;
|
||||
handleFeedback?: (feedback: FEEDBACK) => void;
|
||||
thought?: string;
|
||||
sources?: { title: string; text: string; link: string }[];
|
||||
sources?: {
|
||||
title: string;
|
||||
text: string;
|
||||
link: string;
|
||||
connector_key?: string | null;
|
||||
connector_name?: string | null;
|
||||
}[];
|
||||
toolCalls?: ToolCallsType[];
|
||||
/** Arrival order of the answer's parts; drives inline rendering. */
|
||||
segments?: AnswerSegment[];
|
||||
@@ -369,6 +377,23 @@ const ConversationBubble = forwardRef<
|
||||
</p>
|
||||
</a>
|
||||
</Button>
|
||||
) : source.connector_name ? (
|
||||
<div className="mt-3.5 flex flex-row items-center gap-1.5">
|
||||
<ConnectorIcon
|
||||
icon={connectorIconKey(source.connector_key)}
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
/>
|
||||
<p
|
||||
className="mt-0.5 truncate text-xs"
|
||||
title={source.title}
|
||||
>
|
||||
{t('conversation.sources.fromConnectorTitle', {
|
||||
name: source.connector_name,
|
||||
title: source.title,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3.5 flex flex-row items-center gap-1.5">
|
||||
<FileText className="text-muted-foreground size-4 shrink-0" />
|
||||
@@ -665,7 +690,13 @@ function onActivateKey(
|
||||
}
|
||||
|
||||
type AllSourcesProps = {
|
||||
sources: { title: string; text: string; link?: string }[];
|
||||
sources: {
|
||||
title: string;
|
||||
text: string;
|
||||
link?: string;
|
||||
connector_key?: string | null;
|
||||
connector_name?: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
function AllSources(sources: AllSourcesProps) {
|
||||
@@ -718,6 +749,20 @@ function AllSources(sources: AllSourcesProps) {
|
||||
<ExternalLink className="text-muted-foreground group-hover/card:text-primary ml-1 inline size-3" />
|
||||
)}
|
||||
</p>
|
||||
{source.connector_name && (
|
||||
<p className="text-muted-foreground mt-2 flex items-center gap-1.5 text-xs">
|
||||
<ConnectorIcon
|
||||
icon={connectorIconKey(source.connector_key)}
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{t('conversation.sources.fromConnector', {
|
||||
name: source.connector_name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-foreground mt-3 line-clamp-4 rounded-md text-left text-xs wrap-break-word">
|
||||
{source.text}
|
||||
</p>
|
||||
|
||||
@@ -61,7 +61,14 @@ export interface Query {
|
||||
conversationId?: string | null;
|
||||
title?: string | null;
|
||||
thought?: string;
|
||||
sources?: { title: string; text: string; link: string }[];
|
||||
sources?: {
|
||||
title: string;
|
||||
text: string;
|
||||
link: string;
|
||||
// A chunk synced from a connection names the service, never the account.
|
||||
connector_key?: string | null;
|
||||
connector_name?: string | null;
|
||||
}[];
|
||||
tool_calls?: ToolCallsType[];
|
||||
// Arrival-ordered layout of the fields above, so reasoning and tool calls
|
||||
// render where they happened. Live-stream only; absent on reload, where
|
||||
|
||||
@@ -1066,7 +1066,13 @@
|
||||
"ntfy": "Push-Benachrichtigungen über einen ntfy-Server senden.",
|
||||
"postgres": "Schema lesen und SQL auf einer Postgres-Datenbank ausführen.",
|
||||
"custom_mcp": "Einen beliebigen Remote-Server für das Model Context Protocol verbinden.",
|
||||
"custom_openapi": "Eine OpenAPI-Spezifikation importieren und ihre Endpunkte als Werkzeuge aufrufen."
|
||||
"custom_openapi": "Eine OpenAPI-Spezifikation importieren und ihre Endpunkte als Werkzeuge aufrufen.",
|
||||
"mcp_notion": "Notion-Seiten und -Datenbanken durchsuchen, lesen und aktualisieren.",
|
||||
"mcp_linear": "Linear-Issues und -Projekte finden, anlegen und aktualisieren.",
|
||||
"mcp_atlassian": "Jira-Issues und Confluence-Seiten durchsuchen und aktualisieren.",
|
||||
"mcp_sentry": "Sentry-Issues, -Events und -Releases nachschlagen.",
|
||||
"mcp_asana": "Asana-Aufgaben und -Projekte finden und aktualisieren.",
|
||||
"mcp_stripe": "Kunden, Zahlungen und Abonnements in Stripe nachschlagen."
|
||||
},
|
||||
"empty": "Noch nichts verbunden. Verbinde einen Dienst, um seine Inhalte zu synchronisieren oder Agenten neue Werkzeuge zu geben.",
|
||||
"noMatches": "Keine Konnektoren passen zu deiner Suche.",
|
||||
@@ -1170,6 +1176,18 @@
|
||||
"error": {
|
||||
"decrypt": "Die gespeicherten Zugangsdaten konnten nicht entschlüsselt werden. Verbinde neu, um fortzufahren.",
|
||||
"defaultKey": "Ein Admin muss ENCRYPTION_SECRET_KEY setzen, bevor Dienste verbunden werden können."
|
||||
},
|
||||
"sharing": {
|
||||
"owner": "Teammitglieder nutzen mein Konto",
|
||||
"member": "Jedes Mitglied verbindet sein eigenes Konto"
|
||||
},
|
||||
"share": {
|
||||
"heading": "Welches Konto Teammitglieder nutzen",
|
||||
"ownerWarning": "Teammitglieder handeln in {{name}} als {{account}}.",
|
||||
"confirmWrite": "Ich verstehe, dass Teammitglieder mit meinem Konto Aktionen ausführen können.",
|
||||
"memberNote": "Jedes Mitglied verbindet sein eigenes {{name}}-Konto, bevor es dieses Werkzeug nutzen kann.",
|
||||
"forced": "Ein Admin hat das für alle Freigaben dieses Konnektors festgelegt.",
|
||||
"saveFailed": "Die Änderung konnte nicht gespeichert werden. Versuche es erneut."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1541,7 +1559,10 @@
|
||||
"noSourcesAvailable": "Keine Quellen verfügbar",
|
||||
"selectedCount": "{{count}} Quellen ausgewählt",
|
||||
"groupOwn": "Deine Quellen",
|
||||
"groupTeam": "Mit dem Team geteilt"
|
||||
"groupTeam": "Mit dem Team geteilt",
|
||||
"connectMore": "Weitere verbinden",
|
||||
"fromConnector": "Aus {{name}}",
|
||||
"fromConnectorTitle": "Aus {{name}}: {{title}}"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "Anhängen",
|
||||
@@ -1582,7 +1603,9 @@
|
||||
"accessingMemory": "Greife auf das Gedächtnis zu…",
|
||||
"runningCode": "Führe Code aus…",
|
||||
"creatingArtifact": "Erstelle ein Artefakt…",
|
||||
"usingTool": "Verwende {{tool}}…"
|
||||
"usingTool": "Verwende {{tool}}…",
|
||||
"searchingConnector": "Durchsuche {{name}}...",
|
||||
"usingConnector": "Nutze {{name}}..."
|
||||
},
|
||||
"toolChip": {
|
||||
"searchingWeb": "Im Web gesucht · „{{query}}“",
|
||||
@@ -1593,7 +1616,10 @@
|
||||
"accessingMemory": "Auf Gedächtnis zugegriffen",
|
||||
"runningCode": "Code ausgeführt",
|
||||
"creatingArtifact": "Artefakt erstellt",
|
||||
"usingTool": "{{tool}} verwendet"
|
||||
"usingTool": "{{tool}} verwendet",
|
||||
"searchedConnector": "{{name}} durchsucht",
|
||||
"readConnector": "Aus {{name}} gelesen",
|
||||
"usedConnector": "{{name}} genutzt: {{action}}"
|
||||
},
|
||||
"inlineSteps": {
|
||||
"arguments": "Argumente",
|
||||
@@ -1783,7 +1809,8 @@
|
||||
"noOptionsMessage": "Keine Werkzeuge verfügbar",
|
||||
"groupBuiltin": "Integriert",
|
||||
"groupDefault": "Standard",
|
||||
"groupCustom": "Benutzerdefiniert"
|
||||
"groupCustom": "Benutzerdefiniert",
|
||||
"groupConnection": "{{name}} · {{account}}"
|
||||
},
|
||||
"modelsPopup": {
|
||||
"title": "Modelle auswählen",
|
||||
|
||||
@@ -1072,7 +1072,13 @@
|
||||
"ntfy": "Send push notifications through an ntfy server.",
|
||||
"postgres": "Read the schema and run SQL against a Postgres database.",
|
||||
"custom_mcp": "Connect any remote Model Context Protocol server.",
|
||||
"custom_openapi": "Import an OpenAPI spec and call its endpoints as tools."
|
||||
"custom_openapi": "Import an OpenAPI spec and call its endpoints as tools.",
|
||||
"mcp_notion": "Search, read and update Notion pages and databases.",
|
||||
"mcp_linear": "Find, create and update Linear issues and projects.",
|
||||
"mcp_atlassian": "Search and update Jira issues and Confluence pages.",
|
||||
"mcp_sentry": "Look up Sentry issues, events and releases.",
|
||||
"mcp_asana": "Find and update Asana tasks and projects.",
|
||||
"mcp_stripe": "Look up customers, payments and subscriptions in Stripe."
|
||||
},
|
||||
"empty": "Nothing connected yet. Connect a service to sync its content or give agents new tools.",
|
||||
"noMatches": "No connectors match your search.",
|
||||
@@ -1176,6 +1182,18 @@
|
||||
"error": {
|
||||
"decrypt": "Stored credentials could not be decrypted. Reconnect to continue.",
|
||||
"defaultKey": "An admin must set ENCRYPTION_SECRET_KEY before services can be connected."
|
||||
},
|
||||
"sharing": {
|
||||
"owner": "Team members use my account",
|
||||
"member": "Each member connects their own account"
|
||||
},
|
||||
"share": {
|
||||
"heading": "Whose account team members use",
|
||||
"ownerWarning": "Team members will act as {{account}} on {{name}}.",
|
||||
"confirmWrite": "I understand teammates can take actions with my account.",
|
||||
"memberNote": "Each member connects their own {{name}} account before they can use this tool.",
|
||||
"forced": "An admin chose this for every share of this connector.",
|
||||
"saveFailed": "Could not save the change. Try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1547,7 +1565,10 @@
|
||||
"noSourcesAvailable": "No sources available",
|
||||
"selectedCount": "{{count}} sources selected",
|
||||
"groupOwn": "Your sources",
|
||||
"groupTeam": "Shared with team"
|
||||
"groupTeam": "Shared with team",
|
||||
"connectMore": "Connect more",
|
||||
"fromConnector": "From {{name}}",
|
||||
"fromConnectorTitle": "From {{name}}: {{title}}"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "Attach",
|
||||
@@ -1593,7 +1614,9 @@
|
||||
"accessingMemory": "Accessing memory…",
|
||||
"runningCode": "Running code…",
|
||||
"creatingArtifact": "Creating an artifact…",
|
||||
"usingTool": "Using {{tool}}…"
|
||||
"usingTool": "Using {{tool}}…",
|
||||
"searchingConnector": "Searching {{name}}...",
|
||||
"usingConnector": "Using {{name}}..."
|
||||
},
|
||||
"toolChip": {
|
||||
"searchingWeb": "Searched the web · “{{query}}”",
|
||||
@@ -1604,7 +1627,10 @@
|
||||
"accessingMemory": "Accessed memory",
|
||||
"runningCode": "Ran code",
|
||||
"creatingArtifact": "Created an artifact",
|
||||
"usingTool": "Used {{tool}}"
|
||||
"usingTool": "Used {{tool}}",
|
||||
"searchedConnector": "Searched {{name}}",
|
||||
"readConnector": "Read from {{name}}",
|
||||
"usedConnector": "Used {{name}}: {{action}}"
|
||||
},
|
||||
"inlineSteps": {
|
||||
"arguments": "Arguments",
|
||||
@@ -1799,7 +1825,8 @@
|
||||
"noOptionsMessage": "No tools available",
|
||||
"groupBuiltin": "Built-in",
|
||||
"groupDefault": "Default",
|
||||
"groupCustom": "Custom"
|
||||
"groupCustom": "Custom",
|
||||
"groupConnection": "{{name}} · {{account}}"
|
||||
},
|
||||
"modelsPopup": {
|
||||
"title": "Select Models",
|
||||
@@ -2444,7 +2471,8 @@
|
||||
"admins": "Admins",
|
||||
"usage": "Usage",
|
||||
"quotas": "Quotas",
|
||||
"audit": "Activity"
|
||||
"audit": "Activity",
|
||||
"connectors": "Connectors"
|
||||
},
|
||||
"quotas": {
|
||||
"unpriced": {
|
||||
@@ -2452,6 +2480,9 @@
|
||||
"body": "These were used this {{period}} and recorded at $0: {{models}}. Use a token limit for them, or declare their rates in the model catalog.",
|
||||
"model": "{{model}} ({{tokens}} tokens)"
|
||||
}
|
||||
},
|
||||
"connectors": {
|
||||
"defaultKey": "Set ENCRYPTION_SECRET_KEY before connecting services."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1066,7 +1066,13 @@
|
||||
"ntfy": "Envía notificaciones push mediante un servidor ntfy.",
|
||||
"postgres": "Lee el esquema y ejecuta SQL en una base de datos Postgres.",
|
||||
"custom_mcp": "Conecta cualquier servidor remoto de Model Context Protocol.",
|
||||
"custom_openapi": "Importa una especificación OpenAPI y usa sus endpoints como herramientas."
|
||||
"custom_openapi": "Importa una especificación OpenAPI y usa sus endpoints como herramientas.",
|
||||
"mcp_notion": "Busca, lee y actualiza páginas y bases de datos de Notion.",
|
||||
"mcp_linear": "Encuentra, crea y actualiza incidencias y proyectos de Linear.",
|
||||
"mcp_atlassian": "Busca y actualiza incidencias de Jira y páginas de Confluence.",
|
||||
"mcp_sentry": "Consulta incidencias, eventos y versiones de Sentry.",
|
||||
"mcp_asana": "Encuentra y actualiza tareas y proyectos de Asana.",
|
||||
"mcp_stripe": "Consulta clientes, pagos y suscripciones en Stripe."
|
||||
},
|
||||
"empty": "Aún no hay nada conectado. Conecta un servicio para sincronizar su contenido o dar nuevas herramientas a los agentes.",
|
||||
"noMatches": "Ningún conector coincide con tu búsqueda.",
|
||||
@@ -1170,6 +1176,18 @@
|
||||
"error": {
|
||||
"decrypt": "No se pudieron descifrar las credenciales guardadas. Reconecta para continuar.",
|
||||
"defaultKey": "Un administrador debe definir ENCRYPTION_SECRET_KEY antes de poder conectar servicios."
|
||||
},
|
||||
"sharing": {
|
||||
"owner": "Los miembros usan mi cuenta",
|
||||
"member": "Cada miembro conecta su propia cuenta"
|
||||
},
|
||||
"share": {
|
||||
"heading": "Qué cuenta usan los miembros del equipo",
|
||||
"ownerWarning": "Los miembros del equipo actuarán como {{account}} en {{name}}.",
|
||||
"confirmWrite": "Entiendo que los miembros del equipo pueden realizar acciones con mi cuenta.",
|
||||
"memberNote": "Cada miembro conecta su propia cuenta de {{name}} antes de poder usar esta herramienta.",
|
||||
"forced": "Un administrador eligió esto para todos los recursos compartidos de este conector.",
|
||||
"saveFailed": "No se pudo guardar el cambio. Inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1541,7 +1559,10 @@
|
||||
"noSourcesAvailable": "No hay fuentes disponibles",
|
||||
"selectedCount": "{{count}} fuentes seleccionadas",
|
||||
"groupOwn": "Tus fuentes",
|
||||
"groupTeam": "Compartidas con el equipo"
|
||||
"groupTeam": "Compartidas con el equipo",
|
||||
"connectMore": "Conectar más",
|
||||
"fromConnector": "De {{name}}",
|
||||
"fromConnectorTitle": "De {{name}}: {{title}}"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "Adjuntar",
|
||||
@@ -1582,7 +1603,9 @@
|
||||
"accessingMemory": "Accediendo a la memoria…",
|
||||
"runningCode": "Ejecutando código…",
|
||||
"creatingArtifact": "Creando un artefacto…",
|
||||
"usingTool": "Usando {{tool}}…"
|
||||
"usingTool": "Usando {{tool}}…",
|
||||
"searchingConnector": "Buscando en {{name}}...",
|
||||
"usingConnector": "Usando {{name}}..."
|
||||
},
|
||||
"toolChip": {
|
||||
"searchingWeb": "Buscado en la web · “{{query}}”",
|
||||
@@ -1593,7 +1616,10 @@
|
||||
"accessingMemory": "Memoria consultada",
|
||||
"runningCode": "Código ejecutado",
|
||||
"creatingArtifact": "Artefacto creado",
|
||||
"usingTool": "Usado {{tool}}"
|
||||
"usingTool": "Usado {{tool}}",
|
||||
"searchedConnector": "Buscó en {{name}}",
|
||||
"readConnector": "Leyó de {{name}}",
|
||||
"usedConnector": "Usó {{name}}: {{action}}"
|
||||
},
|
||||
"inlineSteps": {
|
||||
"arguments": "Argumentos",
|
||||
@@ -1783,7 +1809,8 @@
|
||||
"noOptionsMessage": "No hay herramientas disponibles",
|
||||
"groupBuiltin": "Integradas",
|
||||
"groupDefault": "Predeterminadas",
|
||||
"groupCustom": "Personalizadas"
|
||||
"groupCustom": "Personalizadas",
|
||||
"groupConnection": "{{name}} · {{account}}"
|
||||
},
|
||||
"modelsPopup": {
|
||||
"title": "Seleccionar Modelos",
|
||||
|
||||
@@ -1065,7 +1065,13 @@
|
||||
"ntfy": "ntfy サーバー経由でプッシュ通知を送信します。",
|
||||
"postgres": "Postgres データベースのスキーマを読み取り、SQL を実行します。",
|
||||
"custom_mcp": "任意のリモート Model Context Protocol サーバーに接続します。",
|
||||
"custom_openapi": "OpenAPI 仕様をインポートし、そのエンドポイントをツールとして呼び出します。"
|
||||
"custom_openapi": "OpenAPI 仕様をインポートし、そのエンドポイントをツールとして呼び出します。",
|
||||
"mcp_notion": "Notion のページやデータベースを検索、閲覧、更新します。",
|
||||
"mcp_linear": "Linear の課題やプロジェクトを検索、作成、更新します。",
|
||||
"mcp_atlassian": "Jira の課題と Confluence のページを検索、更新します。",
|
||||
"mcp_sentry": "Sentry の課題、イベント、リリースを調べます。",
|
||||
"mcp_asana": "Asana のタスクやプロジェクトを検索、更新します。",
|
||||
"mcp_stripe": "Stripe の顧客、支払い、サブスクリプションを調べます。"
|
||||
},
|
||||
"empty": "まだ何も接続されていません。サービスを接続すると、コンテンツを同期したり、エージェントに新しいツールを提供したりできます。",
|
||||
"noMatches": "検索に一致するコネクタはありません。",
|
||||
@@ -1164,6 +1170,18 @@
|
||||
"error": {
|
||||
"decrypt": "保存された認証情報を復号できませんでした。続行するには再接続してください。",
|
||||
"defaultKey": "サービスを接続する前に、管理者が ENCRYPTION_SECRET_KEY を設定する必要があります。"
|
||||
},
|
||||
"sharing": {
|
||||
"owner": "メンバーは自分のアカウントを使う",
|
||||
"member": "各メンバーが自分のアカウントを接続する"
|
||||
},
|
||||
"share": {
|
||||
"heading": "チームメンバーが使うアカウント",
|
||||
"ownerWarning": "チームメンバーは {{name}} で {{account}} として操作します。",
|
||||
"confirmWrite": "チームメンバーが自分のアカウントで操作を行えることを理解しました。",
|
||||
"memberNote": "各メンバーは、このツールを使う前に自分の {{name}} アカウントを接続します。",
|
||||
"forced": "このコネクタのすべての共有に対して管理者が設定しています。",
|
||||
"saveFailed": "変更を保存できませんでした。もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1533,7 +1551,10 @@
|
||||
"noSourcesAvailable": "利用可能なソースがありません",
|
||||
"selectedCount": "{{count}} 件のソースを選択中",
|
||||
"groupOwn": "自分のソース",
|
||||
"groupTeam": "チームと共有"
|
||||
"groupTeam": "チームと共有",
|
||||
"connectMore": "さらに接続",
|
||||
"fromConnector": "{{name}} から",
|
||||
"fromConnectorTitle": "{{name}} から: {{title}}"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "添付",
|
||||
@@ -1574,7 +1595,9 @@
|
||||
"accessingMemory": "メモリにアクセス中…",
|
||||
"runningCode": "コードを実行中…",
|
||||
"creatingArtifact": "アーティファクトを作成中…",
|
||||
"usingTool": "{{tool}} を使用中…"
|
||||
"usingTool": "{{tool}} を使用中…",
|
||||
"searchingConnector": "{{name}} を検索中...",
|
||||
"usingConnector": "{{name}} を使用中..."
|
||||
},
|
||||
"toolChip": {
|
||||
"searchingWeb": "ウェブ検索済み · 「{{query}}」",
|
||||
@@ -1585,7 +1608,10 @@
|
||||
"accessingMemory": "メモリにアクセス済み",
|
||||
"runningCode": "コード実行済み",
|
||||
"creatingArtifact": "アーティファクト作成済み",
|
||||
"usingTool": "{{tool}} を使用済み"
|
||||
"usingTool": "{{tool}} を使用済み",
|
||||
"searchedConnector": "{{name}} を検索しました",
|
||||
"readConnector": "{{name}} から読み取りました",
|
||||
"usedConnector": "{{name}} を使用: {{action}}"
|
||||
},
|
||||
"inlineSteps": {
|
||||
"arguments": "引数",
|
||||
@@ -1774,7 +1800,8 @@
|
||||
"noOptionsMessage": "利用可能なツールがありません",
|
||||
"groupBuiltin": "ビルトイン",
|
||||
"groupDefault": "デフォルト",
|
||||
"groupCustom": "カスタム"
|
||||
"groupCustom": "カスタム",
|
||||
"groupConnection": "{{name}} · {{account}}"
|
||||
},
|
||||
"modelsPopup": {
|
||||
"title": "モデルを選択",
|
||||
|
||||
@@ -1106,7 +1106,13 @@
|
||||
"ntfy": "Отправляет push-уведомления через сервер ntfy.",
|
||||
"postgres": "Читает схему и выполняет SQL в базе данных Postgres.",
|
||||
"custom_mcp": "Подключает любой удалённый сервер Model Context Protocol.",
|
||||
"custom_openapi": "Импортирует спецификацию OpenAPI и вызывает её эндпоинты как инструменты."
|
||||
"custom_openapi": "Импортирует спецификацию OpenAPI и вызывает её эндпоинты как инструменты.",
|
||||
"mcp_notion": "Поиск, чтение и обновление страниц и баз данных Notion.",
|
||||
"mcp_linear": "Поиск, создание и обновление задач и проектов Linear.",
|
||||
"mcp_atlassian": "Поиск и обновление задач Jira и страниц Confluence.",
|
||||
"mcp_sentry": "Просмотр проблем, событий и релизов Sentry.",
|
||||
"mcp_asana": "Поиск и обновление задач и проектов Asana.",
|
||||
"mcp_stripe": "Просмотр клиентов, платежей и подписок в Stripe."
|
||||
},
|
||||
"empty": "Пока ничего не подключено. Подключите сервис, чтобы синхронизировать его контент или дать агентам новые инструменты.",
|
||||
"noMatches": "Нет коннекторов, подходящих под запрос.",
|
||||
@@ -1220,6 +1226,18 @@
|
||||
"error": {
|
||||
"decrypt": "Не удалось расшифровать сохранённые учётные данные. Переподключитесь, чтобы продолжить.",
|
||||
"defaultKey": "Администратор должен задать ENCRYPTION_SECRET_KEY, прежде чем можно будет подключать сервисы."
|
||||
},
|
||||
"sharing": {
|
||||
"owner": "Участники используют мой аккаунт",
|
||||
"member": "Каждый участник подключает свой аккаунт"
|
||||
},
|
||||
"share": {
|
||||
"heading": "Чей аккаунт используют участники команды",
|
||||
"ownerWarning": "Участники команды будут действовать в {{name}} как {{account}}.",
|
||||
"confirmWrite": "Я понимаю, что участники команды могут выполнять действия от имени моего аккаунта.",
|
||||
"memberNote": "Каждый участник подключает свой аккаунт {{name}}, прежде чем пользоваться этим инструментом.",
|
||||
"forced": "Администратор задал это для всех общих доступов к этому коннектору.",
|
||||
"saveFailed": "Не удалось сохранить изменение. Попробуйте ещё раз."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1595,7 +1613,10 @@
|
||||
"noSourcesAvailable": "Нет доступных источников",
|
||||
"selectedCount": "Выбрано источников: {{count}}",
|
||||
"groupOwn": "Ваши источники",
|
||||
"groupTeam": "Общие с командой"
|
||||
"groupTeam": "Общие с командой",
|
||||
"connectMore": "Подключить ещё",
|
||||
"fromConnector": "Из {{name}}",
|
||||
"fromConnectorTitle": "Из {{name}}: {{title}}"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "Прикрепить",
|
||||
@@ -1636,7 +1657,9 @@
|
||||
"accessingMemory": "Обращаюсь к памяти…",
|
||||
"runningCode": "Выполняю код…",
|
||||
"creatingArtifact": "Создаю артефакт…",
|
||||
"usingTool": "Использую {{tool}}…"
|
||||
"usingTool": "Использую {{tool}}…",
|
||||
"searchingConnector": "Поиск в {{name}}...",
|
||||
"usingConnector": "Использую {{name}}..."
|
||||
},
|
||||
"toolChip": {
|
||||
"searchingWeb": "Поиск в интернете · «{{query}}»",
|
||||
@@ -1647,7 +1670,10 @@
|
||||
"accessingMemory": "Обращение к памяти выполнено",
|
||||
"runningCode": "Код выполнен",
|
||||
"creatingArtifact": "Артефакт создан",
|
||||
"usingTool": "Использован {{tool}}"
|
||||
"usingTool": "Использован {{tool}}",
|
||||
"searchedConnector": "Поиск в {{name}}",
|
||||
"readConnector": "Прочитано из {{name}}",
|
||||
"usedConnector": "Использован {{name}}: {{action}}"
|
||||
},
|
||||
"inlineSteps": {
|
||||
"arguments": "Аргументы",
|
||||
@@ -1839,7 +1865,8 @@
|
||||
"noOptionsMessage": "Нет доступных инструментов",
|
||||
"groupBuiltin": "Встроенные",
|
||||
"groupDefault": "По умолчанию",
|
||||
"groupCustom": "Пользовательские"
|
||||
"groupCustom": "Пользовательские",
|
||||
"groupConnection": "{{name}} · {{account}}"
|
||||
},
|
||||
"modelsPopup": {
|
||||
"title": "Выберите Модели",
|
||||
|
||||
@@ -1065,7 +1065,13 @@
|
||||
"ntfy": "透過 ntfy 伺服器傳送推播通知。",
|
||||
"postgres": "讀取 Postgres 資料庫的結構並執行 SQL。",
|
||||
"custom_mcp": "連接任何遠端 Model Context Protocol 伺服器。",
|
||||
"custom_openapi": "匯入 OpenAPI 規格,並將其端點作為工具呼叫。"
|
||||
"custom_openapi": "匯入 OpenAPI 規格,並將其端點作為工具呼叫。",
|
||||
"mcp_notion": "搜尋、閱讀和更新 Notion 頁面與資料庫。",
|
||||
"mcp_linear": "尋找、建立和更新 Linear 議題與專案。",
|
||||
"mcp_atlassian": "搜尋和更新 Jira 議題與 Confluence 頁面。",
|
||||
"mcp_sentry": "查看 Sentry 問題、事件和發行版本。",
|
||||
"mcp_asana": "尋找和更新 Asana 任務與專案。",
|
||||
"mcp_stripe": "查看 Stripe 中的客戶、付款和訂閱。"
|
||||
},
|
||||
"empty": "尚未連線任何服務。連線一個服務以同步其內容,或為代理提供新工具。",
|
||||
"noMatches": "沒有符合搜尋的連接器。",
|
||||
@@ -1164,6 +1170,18 @@
|
||||
"error": {
|
||||
"decrypt": "無法解密已儲存的憑證。請重新連線以繼續。",
|
||||
"defaultKey": "管理員必須先設定 ENCRYPTION_SECRET_KEY,才能連線服務。"
|
||||
},
|
||||
"sharing": {
|
||||
"owner": "團隊成員使用我的帳號",
|
||||
"member": "每位成員連線自己的帳號"
|
||||
},
|
||||
"share": {
|
||||
"heading": "團隊成員使用誰的帳號",
|
||||
"ownerWarning": "團隊成員將在 {{name}} 中以 {{account}} 的身分操作。",
|
||||
"confirmWrite": "我了解團隊成員可以使用我的帳號執行操作。",
|
||||
"memberNote": "每位成員須先連線自己的 {{name}} 帳號,才能使用此工具。",
|
||||
"forced": "管理員已為此連接器的所有共用設定此項。",
|
||||
"saveFailed": "無法儲存變更。請再試一次。"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1533,7 +1551,10 @@
|
||||
"noSourcesAvailable": "沒有可用的來源",
|
||||
"selectedCount": "已選擇 {{count}} 個來源",
|
||||
"groupOwn": "我的來源",
|
||||
"groupTeam": "團隊共享"
|
||||
"groupTeam": "團隊共享",
|
||||
"connectMore": "連線更多",
|
||||
"fromConnector": "來自 {{name}}",
|
||||
"fromConnectorTitle": "來自 {{name}}:{{title}}"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "附件",
|
||||
@@ -1574,7 +1595,9 @@
|
||||
"accessingMemory": "正在存取記憶…",
|
||||
"runningCode": "正在執行程式碼…",
|
||||
"creatingArtifact": "正在建立 Artifact…",
|
||||
"usingTool": "正在使用 {{tool}}…"
|
||||
"usingTool": "正在使用 {{tool}}…",
|
||||
"searchingConnector": "正在搜尋 {{name}}...",
|
||||
"usingConnector": "正在使用 {{name}}..."
|
||||
},
|
||||
"toolChip": {
|
||||
"searchingWeb": "已搜尋網路 · 「{{query}}」",
|
||||
@@ -1585,7 +1608,10 @@
|
||||
"accessingMemory": "已存取記憶",
|
||||
"runningCode": "已執行程式碼",
|
||||
"creatingArtifact": "已建立 Artifact",
|
||||
"usingTool": "已使用 {{tool}}"
|
||||
"usingTool": "已使用 {{tool}}",
|
||||
"searchedConnector": "已搜尋 {{name}}",
|
||||
"readConnector": "已從 {{name}} 讀取",
|
||||
"usedConnector": "已使用 {{name}}:{{action}}"
|
||||
},
|
||||
"inlineSteps": {
|
||||
"arguments": "參數",
|
||||
@@ -1774,7 +1800,8 @@
|
||||
"noOptionsMessage": "沒有可用的工具",
|
||||
"groupBuiltin": "內建",
|
||||
"groupDefault": "預設",
|
||||
"groupCustom": "自訂"
|
||||
"groupCustom": "自訂",
|
||||
"groupConnection": "{{name}} · {{account}}"
|
||||
},
|
||||
"modelsPopup": {
|
||||
"title": "選擇模型",
|
||||
|
||||
@@ -1065,7 +1065,13 @@
|
||||
"ntfy": "通过 ntfy 服务器发送推送通知。",
|
||||
"postgres": "读取 Postgres 数据库的结构并执行 SQL。",
|
||||
"custom_mcp": "连接任意远程 Model Context Protocol 服务器。",
|
||||
"custom_openapi": "导入 OpenAPI 规范,并将其端点作为工具调用。"
|
||||
"custom_openapi": "导入 OpenAPI 规范,并将其端点作为工具调用。",
|
||||
"mcp_notion": "搜索、阅读和更新 Notion 页面与数据库。",
|
||||
"mcp_linear": "查找、创建和更新 Linear 问题与项目。",
|
||||
"mcp_atlassian": "搜索和更新 Jira 问题与 Confluence 页面。",
|
||||
"mcp_sentry": "查看 Sentry 问题、事件和发布版本。",
|
||||
"mcp_asana": "查找和更新 Asana 任务与项目。",
|
||||
"mcp_stripe": "查看 Stripe 中的客户、付款和订阅。"
|
||||
},
|
||||
"empty": "尚未连接任何服务。连接一个服务以同步其内容,或为智能体提供新工具。",
|
||||
"noMatches": "没有与搜索匹配的连接器。",
|
||||
@@ -1164,6 +1170,18 @@
|
||||
"error": {
|
||||
"decrypt": "无法解密保存的凭据。请重新连接以继续。",
|
||||
"defaultKey": "管理员必须先设置 ENCRYPTION_SECRET_KEY,才能连接服务。"
|
||||
},
|
||||
"sharing": {
|
||||
"owner": "团队成员使用我的账号",
|
||||
"member": "每位成员连接自己的账号"
|
||||
},
|
||||
"share": {
|
||||
"heading": "团队成员使用谁的账号",
|
||||
"ownerWarning": "团队成员将在 {{name}} 中以 {{account}} 的身份操作。",
|
||||
"confirmWrite": "我了解团队成员可以使用我的账号执行操作。",
|
||||
"memberNote": "每位成员需先连接自己的 {{name}} 账号才能使用此工具。",
|
||||
"forced": "管理员已为此连接器的所有共享设定了此项。",
|
||||
"saveFailed": "无法保存更改。请重试。"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1533,7 +1551,10 @@
|
||||
"noSourcesAvailable": "没有可用的来源",
|
||||
"selectedCount": "已选择 {{count}} 个来源",
|
||||
"groupOwn": "我的来源",
|
||||
"groupTeam": "团队共享"
|
||||
"groupTeam": "团队共享",
|
||||
"connectMore": "连接更多",
|
||||
"fromConnector": "来自 {{name}}",
|
||||
"fromConnectorTitle": "来自 {{name}}:{{title}}"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "附件",
|
||||
@@ -1574,7 +1595,9 @@
|
||||
"accessingMemory": "正在访问记忆…",
|
||||
"runningCode": "正在运行代码…",
|
||||
"creatingArtifact": "正在创建工件…",
|
||||
"usingTool": "正在使用 {{tool}}…"
|
||||
"usingTool": "正在使用 {{tool}}…",
|
||||
"searchingConnector": "正在搜索 {{name}}...",
|
||||
"usingConnector": "正在使用 {{name}}..."
|
||||
},
|
||||
"toolChip": {
|
||||
"searchingWeb": "已搜索网络 · “{{query}}”",
|
||||
@@ -1585,7 +1608,10 @@
|
||||
"accessingMemory": "已访问记忆",
|
||||
"runningCode": "已运行代码",
|
||||
"creatingArtifact": "已创建工件",
|
||||
"usingTool": "已使用 {{tool}}"
|
||||
"usingTool": "已使用 {{tool}}",
|
||||
"searchedConnector": "已搜索 {{name}}",
|
||||
"readConnector": "已从 {{name}} 读取",
|
||||
"usedConnector": "已使用 {{name}}:{{action}}"
|
||||
},
|
||||
"inlineSteps": {
|
||||
"arguments": "参数",
|
||||
@@ -1774,7 +1800,8 @@
|
||||
"noOptionsMessage": "没有可用的工具",
|
||||
"groupBuiltin": "内置",
|
||||
"groupDefault": "默认",
|
||||
"groupCustom": "自定义"
|
||||
"groupCustom": "自定义",
|
||||
"groupConnection": "{{name}} · {{account}}"
|
||||
},
|
||||
"modelsPopup": {
|
||||
"title": "选择模型",
|
||||
|
||||
@@ -558,7 +558,12 @@ export default function MCPServerModal({
|
||||
? t('settings.tools.mcp.reconnectServer', {
|
||||
defaultValue: 'Reconnect Server',
|
||||
})
|
||||
: t('settings.tools.mcp.addServer')
|
||||
: server?.preset
|
||||
? t('settings.connectors.wizard.connectTitle', {
|
||||
name: server.displayName,
|
||||
interpolation: { escapeValue: false },
|
||||
})
|
||||
: t('settings.tools.mcp.addServer')
|
||||
}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
|
||||
@@ -220,6 +220,13 @@ export const ADMIN_SECTION: Section = {
|
||||
labelKey: 'admin.tabs.quotas',
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
key: 'connectors',
|
||||
path: '/admin/connectors',
|
||||
labelKey: 'admin.tabs.connectors',
|
||||
icon: Plug,
|
||||
feature: 'connectors',
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
path: '/admin/audit',
|
||||
|
||||
@@ -83,9 +83,20 @@ export default function Connectors() {
|
||||
const custom = catalog.filter((c) => c.publisher === 'custom');
|
||||
const openConnector = catalog.find((c) => c.key === openKey) ?? null;
|
||||
|
||||
// "Connect more" in the composer opens the connectors that can do what the
|
||||
// picker is for: sync content, or give tools.
|
||||
const capability = searchParams.get('capability');
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return catalog
|
||||
.filter((connector) =>
|
||||
capability === 'sync'
|
||||
? connector.capabilities.includes('sync')
|
||||
: capability === 'tools'
|
||||
? connector.capabilities.some((c) => c !== 'sync')
|
||||
: true,
|
||||
)
|
||||
.filter((connector) => {
|
||||
if (filter === 'connected') return isConnected(connector);
|
||||
if (filter !== 'all') return connector.category === filter;
|
||||
@@ -98,7 +109,7 @@ export default function Connectors() {
|
||||
connectorDescription(t, connector).toLowerCase().includes(query),
|
||||
)
|
||||
.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]);
|
||||
}, [catalog, filter, search, t]);
|
||||
}, [catalog, filter, search, t, capability]);
|
||||
|
||||
const open = (connector: ConnectorDefinition) => {
|
||||
if (connector.state === 'available' || connector.state === 'custom') {
|
||||
|
||||
@@ -20,6 +20,7 @@ import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import {
|
||||
loadConnectors,
|
||||
selectConnections,
|
||||
selectConnectorCatalog,
|
||||
} from '../connectors/connectorsSlice';
|
||||
import { useLoaderState } from '../hooks';
|
||||
import AddToolModal from '../modals/AddToolModal';
|
||||
@@ -28,7 +29,9 @@ import MCPServerModal from '../modals/MCPServerModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import type { AppDispatch } from '../store';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import ShareToTeamModal, {
|
||||
type ShareCredentials,
|
||||
} from '../teams/ShareToTeamModal';
|
||||
import RemoteDeviceConfig from './RemoteDeviceConfig';
|
||||
import ToolConfig from './ToolConfig';
|
||||
import { APIToolType, UserToolType } from './types';
|
||||
@@ -38,6 +41,7 @@ export default function Tools() {
|
||||
const token = useSelector(selectToken);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const connections = useSelector(selectConnections);
|
||||
const catalog = useSelector(selectConnectorCatalog);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -64,6 +68,30 @@ export default function Tools() {
|
||||
[toolId: string]: string;
|
||||
}>({});
|
||||
|
||||
// A connection-backed tool shares its connection's account or asks each
|
||||
// member to connect their own; the share dialog shows that choice.
|
||||
const shareCredentials = (
|
||||
tool: UserToolType,
|
||||
): ShareCredentials | undefined => {
|
||||
const connection = tool.connection_id
|
||||
? connections.find((c) => c.id === tool.connection_id)
|
||||
: undefined;
|
||||
if (!connection) return undefined;
|
||||
const policy = catalog.find(
|
||||
(c) => c.key === connection.connector_key,
|
||||
)?.credential_policy;
|
||||
return {
|
||||
toolId: tool.id,
|
||||
connectorName: connection.name,
|
||||
account: connection.account_label,
|
||||
mode: tool.credential_mode === 'member' ? 'member' : 'owner',
|
||||
forcedMode: policy === 'owner' || policy === 'member' ? policy : null,
|
||||
hasWrites: (tool.actions ?? []).some(
|
||||
(action) => action.access === 'write',
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const handleDeleteTool = (tool: UserToolType) => {
|
||||
setToolToDelete(tool);
|
||||
setDeleteModalState('ACTIVE');
|
||||
@@ -478,7 +506,11 @@ export default function Tools() {
|
||||
resourceType="tool"
|
||||
resourceId={toolToShare.id}
|
||||
resourceName={toolToShare.customName || toolToShare.displayName}
|
||||
onClose={() => setToolToShare(null)}
|
||||
credentials={shareCredentials(toolToShare)}
|
||||
onClose={() => {
|
||||
setToolToShare(null);
|
||||
getUserTools();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -170,6 +170,8 @@ export type UserToolType = {
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
// The connection whose account or credentials the tool runs with.
|
||||
connection_id?: string | null;
|
||||
// Whether team members use the owner's account or their own.
|
||||
credential_mode?: 'owner' | 'member';
|
||||
config: {
|
||||
[key: string]: any;
|
||||
};
|
||||
@@ -193,6 +195,8 @@ export type UserToolType = {
|
||||
};
|
||||
active: boolean;
|
||||
require_approval?: boolean;
|
||||
// Read or write, set on tools that come from a connection.
|
||||
access?: 'read' | 'write';
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.account ? `${key}|${opts.account}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/teamsService', () => ({
|
||||
default: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
teams: [{ id: 't1', name: 'Support', slug: 'support', owner_id: 'me' }],
|
||||
}),
|
||||
listResourceShares: vi.fn().mockResolvedValue({ shares: [] }),
|
||||
listMembers: vi.fn().mockResolvedValue({ members: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
const setCredentialMode = vi.fn();
|
||||
vi.mock('../api/services/connectorsService', () => ({
|
||||
default: {
|
||||
setCredentialMode: (...args: unknown[]) => setCredentialMode(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { prefSlice } from '../preferences/preferenceSlice';
|
||||
import ShareToTeamModal, { type ShareCredentials } from './ShareToTeamModal';
|
||||
import teamsReducer from './teamsSlice';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const credentials = (
|
||||
overrides: Partial<ShareCredentials> = {},
|
||||
): ShareCredentials => ({
|
||||
toolId: 'tool-1',
|
||||
connectorName: 'Linear',
|
||||
account: 'lena@meridian.example',
|
||||
mode: 'owner',
|
||||
hasWrites: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('ShareToTeamModal credentials', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
setCredentialMode.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (creds?: ShareCredentials) => {
|
||||
const store = configureStore({
|
||||
reducer: { preference: prefSlice.reducer, teams: teamsReducer },
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<ShareToTeamModal
|
||||
resourceType="tool"
|
||||
resourceId="tool-1"
|
||||
resourceName="Linear"
|
||||
credentials={creds}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const toggle = (value: 'owner' | 'member') =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-slot="option-card"]',
|
||||
),
|
||||
).find((item) =>
|
||||
item.textContent?.includes(`settings.connectors.sharing.${value}`),
|
||||
)!;
|
||||
const picker = () =>
|
||||
document.body.querySelector<HTMLButtonElement>('[role="combobox"]')!;
|
||||
|
||||
it('shows nothing about accounts for a resource without a connection', async () => {
|
||||
await render();
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'settings.connectors.share.heading',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns that members act as the owner in owner mode', async () => {
|
||||
await render(credentials());
|
||||
expect(toggle('owner').getAttribute('aria-checked')).toBe('true');
|
||||
expect(document.body.textContent).toContain(
|
||||
'settings.connectors.share.ownerWarning|lena@meridian.example',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks sharing an owner-mode tool with writes until confirmed', async () => {
|
||||
await render(credentials({ hasWrites: true }));
|
||||
expect(picker().disabled).toBe(true);
|
||||
const box = document.body.querySelector<HTMLButtonElement>(
|
||||
'#share-confirm-writes',
|
||||
)!;
|
||||
await act(async () => box.click());
|
||||
expect(picker().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('saves a mode change and rolls back when the save fails', async () => {
|
||||
setCredentialMode.mockResolvedValue({ success: false });
|
||||
await render(credentials({ hasWrites: true }));
|
||||
await act(async () => toggle('member').click());
|
||||
expect(setCredentialMode).toHaveBeenCalledWith('tool-1', 'member', null);
|
||||
expect(toggle('owner').getAttribute('aria-checked')).toBe('true');
|
||||
expect(document.body.textContent).toContain(
|
||||
'settings.connectors.share.saveFailed',
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the other mode when an admin forces one', async () => {
|
||||
await render(credentials({ forcedMode: 'member' }));
|
||||
expect(toggle('member').getAttribute('aria-checked')).toBe('true');
|
||||
expect(toggle('owner').disabled).toBe(true);
|
||||
expect(document.body.textContent).toContain(
|
||||
'settings.connectors.share.forced',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlert, Trash2 } from 'lucide-react';
|
||||
import { CircleAlert, Trash2, UserRound, UsersRound } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -9,7 +9,11 @@ import teamsService, {
|
||||
ResourceType,
|
||||
TeamMember,
|
||||
} from '../api/services/teamsService';
|
||||
import connectorsService from '../api/services/connectorsService';
|
||||
import { Alert, AlertDescription } from '../components/ui/alert';
|
||||
import { Checkbox } from '../components/ui/checkbox';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { OptionCard } from '../components/ui/option-card';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
@@ -41,10 +45,23 @@ import { AppDispatch } from '../store';
|
||||
import { decodeJwtPayload } from '../utils/jwtUtils';
|
||||
import { loadTeams, selectTeams } from './teamsSlice';
|
||||
|
||||
/** A connection-backed tool: whose account shares of it run with. */
|
||||
export type ShareCredentials = {
|
||||
toolId: string;
|
||||
connectorName: string;
|
||||
account: string;
|
||||
mode: 'owner' | 'member';
|
||||
/** Set when an admin forces one mode for every share of this connector. */
|
||||
forcedMode?: 'owner' | 'member' | null;
|
||||
/** Owner-mode shares of a tool with write actions need an explicit OK. */
|
||||
hasWrites: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
resourceName?: string;
|
||||
credentials?: ShareCredentials;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -84,6 +101,7 @@ export default function ShareToTeamModal({
|
||||
resourceType,
|
||||
resourceId,
|
||||
resourceName,
|
||||
credentials,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -101,6 +119,27 @@ export default function ShareToTeamModal({
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const [credentialMode, setCredentialMode] = useState<'owner' | 'member'>(
|
||||
credentials?.forcedMode ?? credentials?.mode ?? 'owner',
|
||||
);
|
||||
const [writesConfirmed, setWritesConfirmed] = useState(false);
|
||||
const needsWriteConfirm =
|
||||
!!credentials && credentialMode === 'owner' && credentials.hasWrites;
|
||||
const changeCredentialMode = (mode: 'owner' | 'member') => {
|
||||
if (!credentials || mode === credentialMode) return;
|
||||
const previous = credentialMode;
|
||||
setCredentialMode(mode);
|
||||
connectorsService
|
||||
.setCredentialMode(credentials.toolId, mode, token)
|
||||
.then((data) => {
|
||||
if (!data?.success) throw new Error('save failed');
|
||||
})
|
||||
.catch(() => {
|
||||
setCredentialMode(previous);
|
||||
setActionError(t('settings.connectors.share.saveFailed'));
|
||||
});
|
||||
};
|
||||
|
||||
// The access level applied to the next suggestion picked from the combobox.
|
||||
const [accessLevel, setAccessLevel] = useState<AccessLevel>('viewer');
|
||||
|
||||
@@ -508,6 +547,76 @@ export default function ShareToTeamModal({
|
||||
<p className="mt-6 text-sm">{t('settings.teams.share.noTeams')}</p>
|
||||
) : (
|
||||
<>
|
||||
{credentials && (
|
||||
<section className="mt-6 flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('settings.connectors.share.heading')}
|
||||
/>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.connectors.share.heading')}
|
||||
className="grid gap-3 sm:grid-cols-2"
|
||||
>
|
||||
{(['owner', 'member'] as const).map((mode) => (
|
||||
<OptionCard
|
||||
key={mode}
|
||||
icon={mode === 'owner' ? <UserRound /> : <UsersRound />}
|
||||
title={t(`settings.connectors.sharing.${mode}`)}
|
||||
selected={credentialMode === mode}
|
||||
disabled={
|
||||
!!credentials.forcedMode &&
|
||||
credentials.forcedMode !== mode
|
||||
}
|
||||
onClick={() => changeCredentialMode(mode)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{credentials.forcedMode && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.connectors.share.forced')}
|
||||
</p>
|
||||
)}
|
||||
{credentialMode === 'owner' ? (
|
||||
<Alert variant="warning" role="note">
|
||||
<CircleAlert aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.connectors.share.ownerWarning', {
|
||||
account: credentials.account,
|
||||
name: credentials.connectorName,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('settings.connectors.share.memberNote', {
|
||||
name: credentials.connectorName,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{needsWriteConfirm && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="share-confirm-writes"
|
||||
checked={writesConfirmed}
|
||||
onCheckedChange={(checked) =>
|
||||
setWritesConfirmed(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="share-confirm-writes"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{t('settings.connectors.share.confirmWrite')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Add row: type-ahead combobox + access level select. */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
@@ -517,7 +626,9 @@ export default function ShareToTeamModal({
|
||||
variant="combobox"
|
||||
role="combobox"
|
||||
aria-expanded={pickerOpen}
|
||||
disabled={committing}
|
||||
disabled={
|
||||
committing || (needsWriteConfirm && !writesConfirmed)
|
||||
}
|
||||
data-placeholder=""
|
||||
className="min-w-0 flex-1 justify-start"
|
||||
>
|
||||
|
||||
@@ -125,4 +125,64 @@ describe('getToolChipLabel', () => {
|
||||
),
|
||||
).toBe('conversation.toolChip.usingTool|Mcp Tool');
|
||||
});
|
||||
it('names a connection-backed call after its service', () => {
|
||||
const notion = { tool_name: 'mcp_tool', connector_name: 'Notion' };
|
||||
expect(
|
||||
getToolChipLabel(
|
||||
call({ ...notion, action_name: 'notion-search', access: 'read' }),
|
||||
t,
|
||||
),
|
||||
).toBe('conversation.toolChip.searchedConnector|Notion');
|
||||
expect(
|
||||
getToolChipLabel(
|
||||
call({
|
||||
...notion,
|
||||
action_name: 'notion-fetch',
|
||||
access: 'read',
|
||||
status: 'pending',
|
||||
}),
|
||||
t,
|
||||
),
|
||||
).toBe('conversation.streamingStatus.usingConnector|Notion');
|
||||
expect(
|
||||
getToolChipLabel(
|
||||
call({ ...notion, action_name: 'notion-fetch', access: 'read' }),
|
||||
t,
|
||||
),
|
||||
).toBe('conversation.toolChip.readConnector|Notion');
|
||||
});
|
||||
|
||||
it('names the action of a write without the service prefix', () => {
|
||||
expect(
|
||||
getToolChipLabel(
|
||||
call({
|
||||
tool_name: 'mcp_tool',
|
||||
connector_name: 'Linear',
|
||||
action_name: 'linear_create_issue',
|
||||
access: 'write',
|
||||
}),
|
||||
t,
|
||||
),
|
||||
).toBe('conversation.toolChip.usedConnector|Linear,create issue');
|
||||
expect(
|
||||
getToolChipLabel(
|
||||
call({
|
||||
tool_name: 'telegram',
|
||||
connector_name: 'Telegram',
|
||||
action_name: 'send_message',
|
||||
access: 'write',
|
||||
}),
|
||||
t,
|
||||
),
|
||||
).toBe('conversation.toolChip.usedConnector|Telegram,send message');
|
||||
});
|
||||
|
||||
it('keeps built-in labels for web search even with a connector name', () => {
|
||||
expect(
|
||||
getToolChipLabel(
|
||||
call({ connector_name: 'Brave', arguments: { query: 'docsgpt' } }),
|
||||
t,
|
||||
),
|
||||
).toBe('conversation.toolChip.searchingWeb|docsgpt');
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,53 @@ const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
*/
|
||||
export type ToolActivity = { key: string; values?: Record<string, string> };
|
||||
|
||||
const CONNECTOR_SEARCH_WORDS = /search|query|find|list/i;
|
||||
|
||||
/**
|
||||
* A call to a connection-backed tool, named after its service: "Searched
|
||||
* Notion", "Read from Google Drive", "Used Linear: create issue". Reads that
|
||||
* look up something search; other reads read; writes name their action.
|
||||
*/
|
||||
function describeConnectorCall(toolCall: ToolCallsType): ToolActivity | null {
|
||||
const name = toolCall.connector_name;
|
||||
if (!name) return null;
|
||||
const action = toolCall.action_name ?? '';
|
||||
if (toolCall.access === 'write') {
|
||||
const readable = action
|
||||
.replace(/^[a-z0-9]+[_-](?=[a-z])/i, (prefix) =>
|
||||
// "linear_create_issue" and "notion-create-pages" drop the service
|
||||
// prefix; a bare verb ("search") keeps its whole name.
|
||||
name.toLowerCase().startsWith(prefix.slice(0, -1).toLowerCase())
|
||||
? ''
|
||||
: prefix,
|
||||
)
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return { key: 'connectorWrite', values: { name, action: readable } };
|
||||
}
|
||||
if (CONNECTOR_SEARCH_WORDS.test(action))
|
||||
return { key: 'connectorSearch', values: { name } };
|
||||
return { key: 'connectorRead', values: { name } };
|
||||
}
|
||||
|
||||
// Connector activities read differently while running and once done.
|
||||
const CONNECTOR_KEYS: Record<
|
||||
'streamingStatus' | 'toolChip',
|
||||
Record<string, string>
|
||||
> = {
|
||||
streamingStatus: {
|
||||
connectorSearch: 'searchingConnector',
|
||||
connectorRead: 'usingConnector',
|
||||
connectorWrite: 'usingConnector',
|
||||
},
|
||||
toolChip: {
|
||||
connectorSearch: 'searchedConnector',
|
||||
connectorRead: 'readConnector',
|
||||
connectorWrite: 'usedConnector',
|
||||
},
|
||||
};
|
||||
|
||||
export function describeToolCall(toolCall: ToolCallsType): ToolActivity {
|
||||
const { tool_name, action_name, arguments: args } = toolCall;
|
||||
const query = typeof args?.query === 'string' ? args.query : undefined;
|
||||
@@ -63,6 +110,8 @@ export function describeToolCall(toolCall: ToolCallsType): ToolActivity {
|
||||
return { key: 'readingPage', values: { target } };
|
||||
}
|
||||
}
|
||||
const connector = describeConnectorCall(toolCall);
|
||||
if (connector) return connector;
|
||||
if (action_name === 'run_code') return { key: 'runningCode' };
|
||||
if (ARTIFACT_ACTIONS.has(action_name)) return { key: 'creatingArtifact' };
|
||||
if (tool_name === 'internal_search') return { key: 'searchingKnowledge' };
|
||||
@@ -84,7 +133,9 @@ function activityLabel(
|
||||
t: TFunction,
|
||||
): string {
|
||||
const key =
|
||||
activity.key === 'web' ? GENERIC_SEARCH_KEY[namespace] : activity.key;
|
||||
activity.key === 'web'
|
||||
? GENERIC_SEARCH_KEY[namespace]
|
||||
: (CONNECTOR_KEYS[namespace][activity.key] ?? activity.key);
|
||||
return t(
|
||||
`conversation.${namespace}.${key}`,
|
||||
activity.values ? { ...activity.values, ...NO_ESCAPE } : undefined,
|
||||
|
||||
Reference in new issue
Block a user