Fix a tool's parameters from the connector drawer

Under Customize, each action now opens its parameters. Each one is either
left to the AI or set to Always use a value, which is sent on every call
and never shown to the AI. Actions with fixed values carry a small count.
This commit is contained in:
arc53-machine committed 2026-09-29 10:41:37 +01:00
1 parent 28b56e8b03
commit 744cfde565
13 files changed
+529 -51

No files matched your search

+2
View File
@@ -123,6 +123,8 @@ const endpoints = {
`/api/connections/${encodeURIComponent(id)}/tools/${encodeURIComponent(toolId)}/permissions`,
CONNECTION_REPOSITORIES: (id: string) =>
`/api/connections/${encodeURIComponent(id)}/repositories`,
CONNECTION_TOOL_PARAMETERS: (id: string, toolId: string) =>
`/api/connections/${encodeURIComponent(id)}/tools/${encodeURIComponent(toolId)}/parameters`,
CONNECTIONS_CLAIM: '/api/connections/claim',
TOOL_CREDENTIAL_MODE: (toolId: string) =>
`/api/connections/tools/${encodeURIComponent(toolId)}/credential-mode`,
@@ -111,6 +111,21 @@ const connectorsService = {
token,
),
),
/** Fix parameters to a value (`null` lets the AI decide again). */
setToolParameters: async (
id: string,
toolId: string,
action: string,
parameters: Record<string, string | number | boolean | null>,
token: string | null,
) =>
json(
await apiClient.put(
endpoints.USER.CONNECTION_TOOL_PARAMETERS(id, toolId),
{ action, parameters },
token,
),
),
setCredentialMode: async (
toolId: string,
mode: 'owner' | 'member',
@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import type { ActionParameter } from './types';
type Mode = 'ai' | 'fixed';
export type ParameterValue = string | number | boolean | null;
const shown = (parameter: ActionParameter) =>
parameter.fixed && parameter.value !== null ? String(parameter.value) : '';
function ParameterRow({
parameter,
readOnly,
onSave,
}: {
parameter: ActionParameter;
readOnly: boolean;
onSave: (value: ParameterValue) => Promise<boolean>;
}) {
const { t } = useTranslation();
const saved = shown(parameter);
const [mode, setMode] = useState<Mode>(parameter.fixed ? 'fixed' : 'ai');
const [draft, setDraft] = useState(saved);
const [saving, setSaving] = useState(false);
// A save (here or elsewhere) brings the stored value back.
useEffect(() => {
setMode(parameter.fixed ? 'fixed' : 'ai');
setDraft(shown(parameter));
}, [parameter]);
const save = async (value: ParameterValue) => {
setSaving(true);
const ok = await onSave(value);
setSaving(false);
if (!ok) {
setMode(parameter.fixed ? 'fixed' : 'ai');
setDraft(saved);
}
};
const choiceLabel = t('settings.connectors.parameters.choiceLabel', {
parameter: parameter.name,
interpolation: { escapeValue: false },
});
return (
<li data-parameter={parameter.name} className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-foreground font-mono text-xs wrap-anywhere">
{parameter.name}
</p>
{parameter.description && (
<p
className="text-muted-foreground line-clamp-2 text-xs"
title={parameter.description}
>
{parameter.description}
</p>
)}
</div>
<div className="bg-muted rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
value={mode}
disabled={readOnly || saving}
aria-label={choiceLabel}
onValueChange={(value) => {
if (!value) return;
setMode(value as Mode);
// Releasing a fixed value saves at once; fixing one waits for it.
if (value === 'ai' && parameter.fixed) save(null);
}}
>
<ToggleGroupItem value="ai" data-mode="ai">
{t('settings.connectors.parameters.ai')}
</ToggleGroupItem>
<ToggleGroupItem value="fixed" data-mode="fixed">
{t('settings.connectors.parameters.fixed')}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
{mode === 'fixed' && (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
if (draft.trim() && draft !== saved) save(draft);
}}
>
<Input
size="sm"
className="flex-1"
aria-label={t('settings.connectors.parameters.valueLabel', {
parameter: parameter.name,
interpolation: { escapeValue: false },
})}
value={draft}
disabled={readOnly}
onChange={(event) => setDraft(event.target.value)}
/>
<Button
type="submit"
size="sm"
shape="pill"
variant="outline"
loading={saving}
disabled={readOnly || !draft.trim() || draft === saved}
>
{t('settings.connectors.parameters.save')}
</Button>
</form>
)}
</li>
);
}
/**
* One action's parameters, each left to the AI or fixed to a value that is
* sent on every call and never shown to the AI (a Telegram chat, a project
* key). Letting the AI decide saves at once; a fixed value saves with Save.
*/
export default function ActionParameters({
parameters,
readOnly = false,
onSave,
}: {
parameters: ActionParameter[];
readOnly?: boolean;
/** Saves `{name: value}`; resolves false when the save failed. */
onSave: (changes: Record<string, ParameterValue>) => Promise<boolean>;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<ul className="flex flex-col gap-4">
{parameters.map((parameter) => (
<ParameterRow
key={parameter.name}
parameter={parameter}
readOnly={readOnly}
onSave={(value) => onSave({ [parameter.name]: value })}
/>
))}
</ul>
<p className="text-muted-foreground text-xs">
{t('settings.connectors.parameters.hint')}
</p>
</div>
);
}
@@ -10,12 +10,15 @@ vi.mock('react-i18next', () => ({
}),
}));
const connectors = vi.hoisted(() => ({ setToolPermissions: vi.fn() }));
const connectors = vi.hoisted(() => ({
setToolPermissions: vi.fn(),
setToolParameters: vi.fn(),
}));
vi.mock('../api/services/connectorsService', () => ({ default: connectors }));
import actionToastReducer from '../notifications/actionToastSlice';
import ToolPermissions from './ToolPermissions';
import type { ConnectionTool } from './types';
import type { ActionParameter, ConnectionTool } from './types';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -23,7 +26,21 @@ const action = (
name: string,
access: 'read' | 'write',
permission: 'always' | 'ask' | 'off',
) => ({ name, access, permission, description: `Does ${name}.` });
parameters: ActionParameter[] = [],
) => ({ name, access, permission, description: `Does ${name}.`, parameters });
const param = (
name: string,
fixed: boolean,
value: ActionParameter['value'] = null,
): ActionParameter => ({
name,
description: `The ${name}.`,
type: 'string',
required: false,
fixed,
value,
});
const tool = (actions: ReturnType<typeof action>[]): ConnectionTool =>
({
@@ -127,6 +144,105 @@ describe('ToolPermissions', () => {
expect(container.textContent).not.toContain('Old one');
});
const button = (text: string) =>
Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === text,
)!;
const typeInto = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
};
it('marks an action that has fixed values', async () => {
await render(
tool([
action('telegram_send_message', 'write', 'ask', [
param('text', false),
param('chat_id', true, '-100'),
]),
]),
);
expect(container.textContent).toContain(
'settings.connectors.parameters.fixedCount:1',
);
});
it('fixes a parameter to a value the AI cannot change', async () => {
const send = action('telegram_send_message', 'write', 'ask', [
param('text', false),
param('chat_id', false),
]);
connectors.setToolParameters.mockResolvedValue({
success: true,
tool: tool([
{
...send,
parameters: [param('text', false), param('chat_id', true, '-100')],
},
]),
});
await render(tool([send]));
await act(async () =>
button('settings.connectors.parameters.show').click(),
);
const row = container.querySelector<HTMLElement>(
'[data-parameter="chat_id"]',
)!;
await act(async () =>
row.querySelector<HTMLButtonElement>('[data-mode="fixed"]')!.click(),
);
const input = row.querySelector<HTMLInputElement>('input')!;
await act(async () => typeInto(input, '-100'));
await act(async () =>
button('settings.connectors.parameters.save').click(),
);
expect(connectors.setToolParameters).toHaveBeenCalledWith(
'conn-1',
'tool-1',
'telegram_send_message',
{ chat_id: '-100' },
null,
);
expect(container.textContent).toContain(
'settings.connectors.parameters.fixedCount:1',
);
});
it('lets the AI decide a fixed parameter again', async () => {
connectors.setToolParameters.mockResolvedValue({
success: true,
tool: tool([]),
});
await render(
tool([
action('telegram_send_message', 'write', 'ask', [
param('chat_id', true, '-100'),
]),
]),
);
await act(async () =>
button('settings.connectors.parameters.show').click(),
);
const row = container.querySelector<HTMLElement>(
'[data-parameter="chat_id"]',
)!;
expect(row.querySelector<HTMLInputElement>('input')!.value).toBe('-100');
await act(async () =>
row.querySelector<HTMLButtonElement>('[data-mode="ai"]')!.click(),
);
expect(connectors.setToolParameters).toHaveBeenCalledWith(
'conn-1',
'tool-1',
'telegram_send_message',
{ chat_id: null },
null,
);
});
it('says what Ask first means outside chat', async () => {
await render(tool([action('create_issue', 'write', 'ask')]));
expect(container.textContent).toContain(
+135 -48
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import connectorsService from '../api/services/connectorsService';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { SectionHeader } from '../components/ui/section-header';
@@ -17,6 +18,7 @@ import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import { formatCount } from '../utils/dateTimeUtils';
import ActionParameters, { type ParameterValue } from './ActionParameters';
import { actionTitle } from './i18n';
import type {
ActionPermission,
@@ -29,11 +31,105 @@ const PERMISSIONS: ActionPermission[] = ['always', 'ask', 'off'];
/** Longer groups start folded to their one group choice. */
const FOLD_AFTER = 5;
/**
* One action under Customize: its name, what it does and its permission,
* with its parameters one click away and a count of the values fixed there.
*/
function ActionRow({
action,
readOnly,
onPermission,
onParameters,
}: {
action: ConnectionToolAction;
readOnly: boolean;
onPermission: (permission: ActionPermission) => void;
onParameters: (changes: Record<string, ParameterValue>) => Promise<boolean>;
}) {
const { t } = useTranslation();
const [showParameters, setShowParameters] = useState(false);
const parameters = action.parameters ?? [];
const fixedCount = parameters.filter((parameter) => parameter.fixed).length;
return (
<li className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col items-start">
<div className="flex max-w-full items-center gap-2">
<p className="text-foreground truncate text-sm" title={action.name}>
{actionTitle(action.name)}
</p>
{fixedCount > 0 && (
<Badge variant="neutral">
{t('settings.connectors.parameters.fixedCount', {
count: fixedCount,
formatted: formatCount(fixedCount),
})}
</Badge>
)}
</div>
{action.description && (
<p
className="text-muted-foreground line-clamp-2 text-xs"
title={action.description}
>
{action.description}
</p>
)}
{parameters.length > 0 && (
<Button
type="button"
variant="link"
size="inline"
className="mt-1"
aria-expanded={showParameters}
onClick={() => setShowParameters(!showParameters)}
>
{showParameters
? t('settings.connectors.parameters.hide')
: t('settings.connectors.parameters.show')}
</Button>
)}
</div>
<Select
value={action.permission}
disabled={readOnly}
onValueChange={(value) => onPermission(value as ActionPermission)}
>
<SelectTrigger
size="sm"
className="w-32 shrink-0"
aria-label={t('settings.connectors.permission.label', {
action: actionTitle(action.name),
})}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERMISSIONS.map((permission) => (
<SelectItem key={permission} value={permission}>
{t(`settings.connectors.permission.${permission}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showParameters && (
<ActionParameters
parameters={parameters}
readOnly={readOnly}
onSave={onParameters}
/>
)}
</li>
);
}
/**
* One tool's actions in two groups, what it looks up and what it does, each
* set at once to Allow / Ask first / Off; single actions can differ under
* Customize. Changes save immediately; a failed save puts the previous
* choices back and says so.
* Customize, where each action also opens its parameters to fix values the
* AI must always use. Changes save immediately; a failed save puts the
* previous choices back and says so.
*/
export default function ToolPermissions({
connectionId,
@@ -83,6 +179,33 @@ export default function ToolPermissions({
});
};
const setParameters = async (
actionName: string,
changes: Record<string, ParameterValue>,
) => {
try {
const data = await connectorsService.setToolParameters(
connectionId,
tool.id,
actionName,
changes,
token,
);
if (!data?.success) throw new Error('save failed');
setActions(data.tool.actions);
onChange?.(data.tool);
return true;
} catch {
dispatch(
showActionToast({
variant: 'destructive',
message: t('settings.connectors.parameters.saveFailed'),
}),
);
return false;
}
};
const groups = (['read', 'write'] as const)
.map((access) => ({
access,
@@ -177,53 +300,17 @@ export default function ToolPermissions({
{open && (
<ul className="flex flex-col gap-3">
{group.actions.map((action) => (
<li
<ActionRow
key={action.name}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<p
className="text-foreground truncate text-sm"
title={action.name}
>
{actionTitle(action.name)}
</p>
{action.description && (
<p
className="text-muted-foreground line-clamp-2 text-xs"
title={action.description}
>
{action.description}
</p>
)}
</div>
<Select
value={action.permission}
disabled={readOnly}
onValueChange={(value) =>
setPermissions({
[action.name]: value as ActionPermission,
})
}
>
<SelectTrigger
size="sm"
className="w-32 shrink-0"
aria-label={t('settings.connectors.permission.label', {
action: actionTitle(action.name),
})}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERMISSIONS.map((permission) => (
<SelectItem key={permission} value={permission}>
{t(`settings.connectors.permission.${permission}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</li>
action={action}
readOnly={readOnly}
onPermission={(permission) =>
setPermissions({ [action.name]: permission })
}
onParameters={(changes) =>
setParameters(action.name, changes)
}
/>
))}
</ul>
)}
+11
View File
@@ -92,11 +92,22 @@ export type Connection = {
export type ActionAccess = 'read' | 'write';
export type ActionPermission = 'always' | 'ask' | 'off';
export type ActionParameter = {
name: string;
description: string;
type: string;
required: boolean;
/** Sent with `value` on every call; the AI never sees it. */
fixed: boolean;
value: string | number | boolean | null;
};
export type ConnectionToolAction = {
name: string;
description: string;
access: ActionAccess;
permission: ActionPermission;
parameters?: ActionParameter[];
};
export type ConnectionTool = {
+13
View File
@@ -1344,6 +1344,19 @@
"repositories": "Repositorys",
"updated": "Aktualisiert {{date}}",
"loadFailed": "Deine Repositorys konnten nicht geladen werden."
},
"parameters": {
"show": "Parameter",
"hide": "Parameter ausblenden",
"fixedCount_one": "{{formatted}} festgelegt",
"fixedCount_other": "{{formatted}} festgelegt",
"ai": "KI entscheidet",
"fixed": "Immer verwenden",
"choiceLabel": "Wie {{parameter}} ausgefüllt wird",
"valueLabel": "Wert für {{parameter}}",
"save": "Speichern",
"saveFailed": "Der Wert konnte nicht gespeichert werden. Versuche es erneut.",
"hint": "Ein Wert mit „Immer verwenden“ wird bei jedem Aufruf gesendet. Die KI sieht ihn nicht und kann ihn nicht ändern."
}
}
},
+13
View File
@@ -1350,6 +1350,19 @@
"repositories": "Repositories",
"updated": "Updated {{date}}",
"loadFailed": "Could not load your repositories."
},
"parameters": {
"show": "Parameters",
"hide": "Hide parameters",
"fixedCount_one": "{{formatted}} fixed",
"fixedCount_other": "{{formatted}} fixed",
"ai": "Let AI decide",
"fixed": "Always use",
"choiceLabel": "How {{parameter}} is filled in",
"valueLabel": "Value for {{parameter}}",
"save": "Save",
"saveFailed": "Could not save the value. Try again.",
"hint": "A value set to Always use is sent on every call. The AI never sees it and cannot change it."
}
}
},
+13
View File
@@ -1344,6 +1344,19 @@
"repositories": "Repositorios",
"updated": "Actualizado {{date}}",
"loadFailed": "No se pudieron cargar tus repositorios."
},
"parameters": {
"show": "Parámetros",
"hide": "Ocultar parámetros",
"fixedCount_one": "{{formatted}} fijo",
"fixedCount_other": "{{formatted}} fijos",
"ai": "Que decida la IA",
"fixed": "Usar siempre",
"choiceLabel": "Cómo se rellena {{parameter}}",
"valueLabel": "Valor de {{parameter}}",
"save": "Guardar",
"saveFailed": "No se pudo guardar el valor. Inténtalo de nuevo.",
"hint": "Un valor con «Usar siempre» se envía en cada llamada. La IA nunca lo ve ni puede cambiarlo."
}
}
},
+12
View File
@@ -1326,6 +1326,18 @@
"repositories": "リポジトリ",
"updated": "{{date}} に更新",
"loadFailed": "リポジトリを読み込めませんでした。"
},
"parameters": {
"show": "パラメーター",
"hide": "パラメーターを隠す",
"fixedCount_other": "{{formatted}} 件固定",
"ai": "AI に任せる",
"fixed": "常に使用",
"choiceLabel": "{{parameter}} の入力方法",
"valueLabel": "{{parameter}} の値",
"save": "保存",
"saveFailed": "値を保存できませんでした。もう一度お試しください。",
"hint": "「常に使用」の値は毎回の呼び出しで送信されます。AI はこの値を見ることも変更することもできません。"
}
}
},
+15
View File
@@ -1418,6 +1418,21 @@
"repositories": "Репозитории",
"updated": "Обновлён {{date}}",
"loadFailed": "Не удалось загрузить ваши репозитории."
},
"parameters": {
"show": "Параметры",
"hide": "Скрыть параметры",
"fixedCount_one": "{{formatted}} задан",
"fixedCount_few": "{{formatted}} задано",
"fixedCount_many": "{{formatted}} задано",
"fixedCount_other": "{{formatted}} задано",
"ai": "Решает ИИ",
"fixed": "Всегда использовать",
"choiceLabel": "Как заполняется {{parameter}}",
"valueLabel": "Значение {{parameter}}",
"save": "Сохранить",
"saveFailed": "Не удалось сохранить значение. Попробуйте ещё раз.",
"hint": "Значение «Всегда использовать» отправляется при каждом вызове. ИИ его не видит и не может изменить."
}
}
},
+12
View File
@@ -1326,6 +1326,18 @@
"repositories": "儲存庫",
"updated": "更新於 {{date}}",
"loadFailed": "無法載入你的儲存庫。"
},
"parameters": {
"show": "參數",
"hide": "隱藏參數",
"fixedCount_other": "已固定 {{formatted}} 個",
"ai": "由 AI 決定",
"fixed": "一律使用",
"choiceLabel": "{{parameter}} 的填寫方式",
"valueLabel": "{{parameter}} 的值",
"save": "儲存",
"saveFailed": "無法儲存此值。請再試一次。",
"hint": "設為「一律使用」的值會在每次呼叫時送出。AI 看不到它,也無法變更。"
}
}
},
+12
View File
@@ -1326,6 +1326,18 @@
"repositories": "仓库",
"updated": "更新于 {{date}}",
"loadFailed": "无法加载你的仓库。"
},
"parameters": {
"show": "参数",
"hide": "隐藏参数",
"fixedCount_other": "已固定 {{formatted}} 个",
"ai": "由 AI 决定",
"fixed": "始终使用",
"choiceLabel": "{{parameter}} 的填写方式",
"valueLabel": "{{parameter}} 的值",
"save": "保存",
"saveFailed": "无法保存该值。请重试。",
"hint": "设为“始终使用”的值会在每次调用时发送。AI 看不到它,也无法更改。"
}
}
},