diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 5b5376f9..4f83a7c6 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -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`, diff --git a/frontend/src/api/services/connectorsService.ts b/frontend/src/api/services/connectorsService.ts index 56356801..d73aedd1 100644 --- a/frontend/src/api/services/connectorsService.ts +++ b/frontend/src/api/services/connectorsService.ts @@ -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, + token: string | null, + ) => + json( + await apiClient.put( + endpoints.USER.CONNECTION_TOOL_PARAMETERS(id, toolId), + { action, parameters }, + token, + ), + ), setCredentialMode: async ( toolId: string, mode: 'owner' | 'member', diff --git a/frontend/src/connectors/ActionParameters.tsx b/frontend/src/connectors/ActionParameters.tsx new file mode 100644 index 00000000..8986bde9 --- /dev/null +++ b/frontend/src/connectors/ActionParameters.tsx @@ -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; +}) { + const { t } = useTranslation(); + const saved = shown(parameter); + const [mode, setMode] = useState(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 ( +
  • +
    +
    +

    + {parameter.name} +

    + {parameter.description && ( +

    + {parameter.description} +

    + )} +
    +
    + { + 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); + }} + > + + {t('settings.connectors.parameters.ai')} + + + {t('settings.connectors.parameters.fixed')} + + +
    +
    + {mode === 'fixed' && ( +
    { + event.preventDefault(); + if (draft.trim() && draft !== saved) save(draft); + }} + > + setDraft(event.target.value)} + /> + +
    + )} +
  • + ); +} + +/** + * 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) => Promise; +}) { + const { t } = useTranslation(); + return ( +
    +
      + {parameters.map((parameter) => ( + onSave({ [parameter.name]: value })} + /> + ))} +
    +

    + {t('settings.connectors.parameters.hint')} +

    +
    + ); +} diff --git a/frontend/src/connectors/ToolPermissions.test.tsx b/frontend/src/connectors/ToolPermissions.test.tsx index 6d02b363..e94a5ade 100644 --- a/frontend/src/connectors/ToolPermissions.test.tsx +++ b/frontend/src/connectors/ToolPermissions.test.tsx @@ -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[]): 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( + '[data-parameter="chat_id"]', + )!; + await act(async () => + row.querySelector('[data-mode="fixed"]')!.click(), + ); + const input = row.querySelector('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( + '[data-parameter="chat_id"]', + )!; + expect(row.querySelector('input')!.value).toBe('-100'); + await act(async () => + row.querySelector('[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( diff --git a/frontend/src/connectors/ToolPermissions.tsx b/frontend/src/connectors/ToolPermissions.tsx index 01c09ac7..84242ff2 100644 --- a/frontend/src/connectors/ToolPermissions.tsx +++ b/frontend/src/connectors/ToolPermissions.tsx @@ -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) => Promise; +}) { + const { t } = useTranslation(); + const [showParameters, setShowParameters] = useState(false); + const parameters = action.parameters ?? []; + const fixedCount = parameters.filter((parameter) => parameter.fixed).length; + return ( +
  • +
    +
    +
    +

    + {actionTitle(action.name)} +

    + {fixedCount > 0 && ( + + {t('settings.connectors.parameters.fixedCount', { + count: fixedCount, + formatted: formatCount(fixedCount), + })} + + )} +
    + {action.description && ( +

    + {action.description} +

    + )} + {parameters.length > 0 && ( + + )} +
    + +
    + {showParameters && ( + + )} +
  • + ); +} + /** * 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, + ) => { + 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 && (
      {group.actions.map((action) => ( -
    • -
      -

      - {actionTitle(action.name)} -

      - {action.description && ( -

      - {action.description} -

      - )} -
      - -
    • + action={action} + readOnly={readOnly} + onPermission={(permission) => + setPermissions({ [action.name]: permission }) + } + onParameters={(changes) => + setParameters(action.name, changes) + } + /> ))}
    )} diff --git a/frontend/src/connectors/types.ts b/frontend/src/connectors/types.ts index 33ed0311..ed9cc6ae 100644 --- a/frontend/src/connectors/types.ts +++ b/frontend/src/connectors/types.ts @@ -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 = { diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 4368a131..5e13a1c0 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -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." } } }, diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 69b598df..1cfd4e9c 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -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." } } }, diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index cb360df7..51ee876d 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -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." } } }, diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 99395e84..d271ecce 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -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 はこの値を見ることも変更することもできません。" } } }, diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index c8c8ad9f..de52534d 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -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": "Значение «Всегда использовать» отправляется при каждом вызове. ИИ его не видит и не может изменить." } } }, diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index c4b13609..3b2903c5 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -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 看不到它,也無法變更。" } } }, diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 991e2844..d22a13d7 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -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 看不到它,也无法更改。" } } },