feat: enhance API tool with body serialization and content type handling (#2192)

* feat: enhance API tool with body serialization and content type handling

* feat: enhance ToolConfig with import functionality and user action management

- Added ImportSpecModal to allow importing actions into the tool configuration.
- Implemented search functionality for user actions with expandable action details.
- Introduced method colors for better visual distinction of HTTP methods.
- Updated APIActionType and ParameterGroupType to include optional 'required' field.
- Refactored action rendering to improve usability and maintainability.

* feat: add base URL input to ImportSpecModal for action URL customization

* feat: update TestBaseAgentTools to include 'required' field for parameters

* feat: standardize API call timeout to DEFAULT_TIMEOUT constant

* feat: add import specification functionality and related translations for multiple languages

---------

Co-authored-by: Alex <a@tushynski.me>
This commit is contained in:
Siddhant Rai
2025-12-23 19:07:44 +05:30
committed by GitHub
parent f91846ce2d
commit 5b6cfa6ecc
18 changed files with 2308 additions and 347 deletions

View File

@@ -40,6 +40,7 @@ const endpoints = {
UPDATE_TOOL_STATUS: '/api/update_tool_status',
UPDATE_TOOL: '/api/update_tool',
DELETE_TOOL: '/api/delete_tool',
PARSE_SPEC: '/api/parse_spec',
SYNC_CONNECTOR: '/api/connectors/sync',
GET_CHUNKS: (
docId: string,

View File

@@ -84,6 +84,11 @@ const userService = {
apiClient.post(endpoints.USER.UPDATE_TOOL, data, token),
deleteTool: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.DELETE_TOOL, data, token),
parseSpec: (file: File, token: string | null): Promise<any> => {
const formData = new FormData();
formData.append('file', file);
return apiClient.postFormData(endpoints.USER.PARSE_SPEC, formData, token);
},
getDocumentChunks: (
docId: string,
page: number,

View File

@@ -162,12 +162,17 @@
"authentication": "Authentifizierung",
"actions": "Aktionen",
"addAction": "Aktion hinzufügen",
"importSpec": "Spezifikation importieren",
"searchActions": "Aktionen suchen...",
"noActionsMatch": "Keine Aktionen passen zu deiner Suche",
"actionAlreadyExists": "Eine Aktion mit diesem Namen existiert bereits",
"noActionsFound": "Keine Aktionen gefunden",
"url": "URL",
"urlPlaceholder": "URL eingeben",
"method": "Methode",
"description": "Beschreibung",
"descriptionPlaceholder": "Beschreibung eingeben",
"bodyContentType": "Body-Inhaltstyp",
"headers": "Header",
"queryParameters": "Abfrageparameter",
"body": "Body",
@@ -441,6 +446,22 @@
"generate": "Generieren",
"test": "Testen",
"learnMore": "Mehr erfahren"
},
"importSpec": {
"title": "API-Spezifikation importieren",
"description": "Lade eine OpenAPI 3.x- oder Swagger 2.0-Spezifikationsdatei hoch, um automatisch Aktionen zu generieren.",
"dropzoneText": "Zum Hochladen klicken oder per Drag & Drop",
"supportedFormats": "JSON- oder YAML-Format",
"invalidFileType": "Ungültiger Dateityp. Bitte eine JSON- oder YAML-Datei hochladen.",
"parseError": "Spezifikation konnte nicht geparst werden. Bitte Dateiformat prüfen.",
"version": "Version",
"baseUrl": "Basis-URL",
"actionsFound": "{{count}} Aktionen gefunden",
"selectAll": "Alle auswählen",
"deselectAll": "Alle abwählen",
"cancel": "Abbrechen",
"parse": "Parsen",
"import": "Importieren ({{count}})"
}
},
"sharedConv": {

View File

@@ -162,12 +162,17 @@
"authentication": "Authentication",
"actions": "Actions",
"addAction": "Add action",
"importSpec": "Import Spec",
"searchActions": "Search actions...",
"noActionsMatch": "No actions match your search",
"actionAlreadyExists": "An action with this name already exists",
"noActionsFound": "No actions found",
"url": "URL",
"urlPlaceholder": "Enter URL",
"method": "Method",
"description": "Description",
"descriptionPlaceholder": "Enter description",
"bodyContentType": "Body Content Type",
"headers": "Headers",
"queryParameters": "Query Parameters",
"body": "Body",
@@ -441,6 +446,22 @@
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
},
"importSpec": {
"title": "Import API Specification",
"description": "Upload an OpenAPI 3.x or Swagger 2.0 specification file to automatically generate actions.",
"dropzoneText": "Click to upload or drag and drop",
"supportedFormats": "JSON or YAML format",
"invalidFileType": "Invalid file type. Please upload a JSON or YAML file.",
"parseError": "Failed to parse the specification. Please check the file format.",
"version": "Version",
"baseUrl": "Base URL",
"actionsFound": "{{count}} actions found",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"cancel": "Cancel",
"parse": "Parse",
"import": "Import ({{count}})"
}
},
"sharedConv": {

View File

@@ -162,12 +162,17 @@
"authentication": "Autenticación",
"actions": "Acciones",
"addAction": "Agregar acción",
"importSpec": "Importar especificación",
"searchActions": "Buscar acciones...",
"noActionsMatch": "No hay acciones que coincidan con tu búsqueda",
"actionAlreadyExists": "Ya existe una acción con este nombre",
"noActionsFound": "No se encontraron acciones",
"url": "URL",
"urlPlaceholder": "Ingresa url",
"method": "Método",
"description": "Descripción",
"descriptionPlaceholder": "Ingresa descripción",
"bodyContentType": "Tipo de contenido del cuerpo",
"headers": "Encabezados",
"queryParameters": "Parámetros de Consulta",
"body": "Cuerpo",
@@ -441,6 +446,22 @@
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
},
"importSpec": {
"title": "Importar especificación de API",
"description": "Sube un archivo de especificación OpenAPI 3.x o Swagger 2.0 para generar acciones automáticamente.",
"dropzoneText": "Haz clic para subir o arrastra y suelta",
"supportedFormats": "Formato JSON o YAML",
"invalidFileType": "Tipo de archivo no válido. Sube un archivo JSON o YAML.",
"parseError": "No se pudo analizar la especificación. Verifica el formato del archivo.",
"version": "Versión",
"baseUrl": "URL base",
"actionsFound": "{{count}} acciones encontradas",
"selectAll": "Seleccionar todo",
"deselectAll": "Deseleccionar todo",
"cancel": "Cancelar",
"parse": "Analizar",
"import": "Importar ({{count}})"
}
},
"sharedConv": {

View File

@@ -162,12 +162,17 @@
"authentication": "認証",
"actions": "アクション",
"addAction": "アクションを追加",
"importSpec": "仕様をインポート",
"searchActions": "アクションを検索...",
"noActionsMatch": "検索に一致するアクションがありません",
"actionAlreadyExists": "この名前のアクションは既に存在します",
"noActionsFound": "アクションが見つかりません",
"url": "URL",
"urlPlaceholder": "URLを入力",
"method": "メソッド",
"description": "説明",
"descriptionPlaceholder": "説明を入力",
"bodyContentType": "ボディのコンテンツタイプ",
"headers": "ヘッダー",
"queryParameters": "クエリパラメータ",
"body": "ボディ",
@@ -441,6 +446,22 @@
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
},
"importSpec": {
"title": "API仕様のインポート",
"description": "OpenAPI 3.x または Swagger 2.0 の仕様ファイルをアップロードして、アクションを自動生成します。",
"dropzoneText": "クリックしてアップロード、またはドラッグ&ドロップ",
"supportedFormats": "JSON または YAML 形式",
"invalidFileType": "無効なファイル形式です。JSON または YAML ファイルをアップロードしてください。",
"parseError": "仕様の解析に失敗しました。ファイル形式を確認してください。",
"version": "バージョン",
"baseUrl": "ベースURL",
"actionsFound": "{{count}} 件のアクションが見つかりました",
"selectAll": "すべて選択",
"deselectAll": "すべて解除",
"cancel": "キャンセル",
"parse": "解析",
"import": "インポート ({{count}})"
}
},
"sharedConv": {

View File

@@ -162,12 +162,17 @@
"authentication": "Аутентификация",
"actions": "Действия",
"addAction": "Добавить действие",
"importSpec": "Импорт спецификации",
"searchActions": "Поиск действий...",
"noActionsMatch": "Нет действий, соответствующих вашему поиску",
"actionAlreadyExists": "Действие с таким именем уже существует",
"noActionsFound": "Действия не найдены",
"url": "URL",
"urlPlaceholder": "Введите URL",
"method": "Метод",
"description": "Описание",
"descriptionPlaceholder": "Введите описание",
"bodyContentType": "Тип содержимого тела",
"headers": "Заголовки",
"queryParameters": "Параметры запроса",
"body": "Тело запроса",
@@ -441,6 +446,22 @@
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
},
"importSpec": {
"title": "Импорт спецификации API",
"description": "Загрузите файл спецификации OpenAPI 3.x или Swagger 2.0 для автоматического создания действий.",
"dropzoneText": "Нажмите для загрузки или перетащите файл",
"supportedFormats": "Формат JSON или YAML",
"invalidFileType": "Неверный тип файла. Пожалуйста, загрузите файл JSON или YAML.",
"parseError": "Не удалось разобрать спецификацию. Проверьте формат файла.",
"version": "Версия",
"baseUrl": "Базовый URL",
"actionsFound": "{{count}} действий найдено",
"selectAll": "Выбрать все",
"deselectAll": "Снять выделение со всех",
"cancel": "Отмена",
"parse": "Разобрать",
"import": "Импорт ({{count}})"
}
},
"sharedConv": {

View File

@@ -162,12 +162,17 @@
"authentication": "認證",
"actions": "操作",
"addAction": "新增操作",
"importSpec": "匯入規格",
"searchActions": "搜尋操作...",
"noActionsMatch": "沒有符合搜尋的操作",
"actionAlreadyExists": "已存在同名操作",
"noActionsFound": "找不到操作",
"url": "URL",
"urlPlaceholder": "輸入url",
"method": "方法",
"description": "描述",
"descriptionPlaceholder": "輸入描述",
"bodyContentType": "主體內容類型",
"headers": "標頭",
"queryParameters": "查詢參數",
"body": "主體",
@@ -441,6 +446,22 @@
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
},
"importSpec": {
"title": "匯入 API 規格",
"description": "上傳 OpenAPI 3.x 或 Swagger 2.0 規格檔以自動產生操作。",
"dropzoneText": "點擊上傳或拖放",
"supportedFormats": "JSON 或 YAML 格式",
"invalidFileType": "無效的檔案類型。請上傳 JSON 或 YAML 檔案。",
"parseError": "解析規格失敗。請檢查檔案格式。",
"version": "版本",
"baseUrl": "基礎 URL",
"actionsFound": "找到 {{count}} 個操作",
"selectAll": "全選",
"deselectAll": "取消全選",
"cancel": "取消",
"parse": "解析",
"import": "匯入 ({{count}})"
}
},
"sharedConv": {

View File

@@ -162,12 +162,17 @@
"authentication": "认证",
"actions": "操作",
"addAction": "添加操作",
"importSpec": "导入规范",
"searchActions": "搜索操作...",
"noActionsMatch": "没有与搜索匹配的操作",
"actionAlreadyExists": "已存在同名操作",
"noActionsFound": "未找到操作",
"url": "URL",
"urlPlaceholder": "输入url",
"method": "方法",
"description": "描述",
"descriptionPlaceholder": "输入描述",
"bodyContentType": "请求体内容类型",
"headers": "请求头",
"queryParameters": "查询参数",
"body": "请求体",
@@ -441,6 +446,22 @@
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
},
"importSpec": {
"title": "导入 API 规范",
"description": "上传 OpenAPI 3.x 或 Swagger 2.0 规范文件以自动生成操作。",
"dropzoneText": "点击上传或拖拽到此处",
"supportedFormats": "JSON 或 YAML 格式",
"invalidFileType": "文件类型无效。请上传 JSON 或 YAML 文件。",
"parseError": "解析规范失败。请检查文件格式。",
"version": "版本",
"baseUrl": "基础 URL",
"actionsFound": "找到 {{count}} 个操作",
"selectAll": "全选",
"deselectAll": "取消全选",
"cancel": "取消",
"parse": "解析",
"import": "导入 ({{count}})"
}
},
"sharedConv": {

View File

@@ -0,0 +1,321 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import Upload from '../assets/upload.svg';
import Spinner from '../components/Spinner';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import { APIActionType } from '../settings/types';
import WrapperModal from './WrapperModal';
interface ImportSpecModalProps {
modalState: ActiveState;
setModalState: (state: ActiveState) => void;
onImport: (actions: APIActionType[]) => void;
}
interface ParsedResult {
metadata: {
title: string;
description: string;
version: string;
base_url: string;
};
actions: APIActionType[];
}
const METHOD_COLORS: Record<string, string> = {
GET: 'bg-[#D1FAE5] text-[#065F46] dark:bg-[#064E3B]/60 dark:text-[#6EE7B7]',
POST: 'bg-[#DBEAFE] text-[#1E40AF] dark:bg-[#1E3A8A]/60 dark:text-[#93C5FD]',
PUT: 'bg-[#FEF3C7] text-[#92400E] dark:bg-[#78350F]/60 dark:text-[#FCD34D]',
DELETE:
'bg-[#FEE2E2] text-[#991B1B] dark:bg-[#7F1D1D]/60 dark:text-[#FCA5A5]',
PATCH: 'bg-[#EDE9FE] text-[#5B21B6] dark:bg-[#4C1D95]/60 dark:text-[#C4B5FD]',
HEAD: 'bg-[#F3F4F6] text-[#374151] dark:bg-[#374151]/60 dark:text-[#D1D5DB]',
OPTIONS:
'bg-[#F3F4F6] text-[#374151] dark:bg-[#374151]/60 dark:text-[#D1D5DB]',
};
export default function ImportSpecModal({
modalState,
setModalState,
onImport,
}: ImportSpecModalProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [parsedResult, setParsedResult] = useState<ParsedResult | null>(null);
const [selectedActions, setSelectedActions] = useState<Set<number>>(
new Set(),
);
const [baseUrl, setBaseUrl] = useState<string>('');
const handleClose = () => {
setModalState('INACTIVE');
setFile(null);
setLoading(false);
setError(null);
setParsedResult(null);
setSelectedActions(new Set());
setBaseUrl('');
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
const validExtensions = ['.json', '.yaml', '.yml'];
const hasValidExtension = validExtensions.some((ext) =>
selectedFile.name.toLowerCase().endsWith(ext),
);
if (!hasValidExtension) {
setError(t('modals.importSpec.invalidFileType'));
return;
}
setFile(selectedFile);
setError(null);
setParsedResult(null);
};
const handleParse = async () => {
if (!file) return;
setLoading(true);
setError(null);
try {
const response = await userService.parseSpec(file, token);
if (!response.ok) {
const errorData = await response.json();
setError(
errorData.error ||
errorData.message ||
t('modals.importSpec.parseError'),
);
return;
}
const result = await response.json();
if (result.success) {
setParsedResult(result);
setBaseUrl(result.metadata.base_url || '');
setSelectedActions(
new Set<number>(
result.actions.map((_: APIActionType, i: number) => i),
),
);
} else {
setError(
result.error || result.message || t('modals.importSpec.parseError'),
);
}
} catch {
setError(t('modals.importSpec.parseError'));
} finally {
setLoading(false);
}
};
const toggleAction = (index: number) => {
setSelectedActions((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
const toggleAll = () => {
if (!parsedResult) return;
if (selectedActions.size === parsedResult.actions.length) {
setSelectedActions(new Set());
} else {
setSelectedActions(new Set(parsedResult.actions.map((_, i) => i)));
}
};
const handleImport = () => {
if (!parsedResult) return;
const actionsToImport = parsedResult.actions
.filter((_, i) => selectedActions.has(i))
.map((action) => ({
...action,
url: action.url.replace(parsedResult.metadata.base_url, baseUrl.trim()),
}));
onImport(actionsToImport);
handleClose();
};
if (modalState !== 'ACTIVE') return null;
return (
<WrapperModal
close={handleClose}
className="w-full max-w-2xl"
contentClassName="max-h-[70vh]"
>
<div className="flex flex-col gap-4">
<h2 className="text-jet dark:text-bright-gray text-xl font-semibold">
{t('modals.importSpec.title')}
</h2>
{!parsedResult ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('modals.importSpec.description')}
</p>
<div
onClick={() => fileInputRef.current?.click()}
className="border-silver dark:border-silver/40 hover:border-purple-30 dark:hover:border-purple-30 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 transition-colors"
>
<img
src={Upload}
alt="Upload"
className="mb-3 h-10 w-10 opacity-60 dark:invert"
/>
<p className="text-jet dark:text-bright-gray text-sm font-medium">
{file ? file.name : t('modals.importSpec.dropzoneText')}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('modals.importSpec.supportedFormats')}
</p>
<input
ref={fileInputRef}
type="file"
accept=".json,.yaml,.yml"
onChange={handleFileChange}
className="hidden"
/>
</div>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
) : (
<div className="flex flex-col gap-4">
<div className="rounded-xl bg-[#F9F9F9] p-4 dark:bg-[#28292D]">
<h3 className="text-jet dark:text-bright-gray font-medium">
{parsedResult.metadata.title}
</h3>
{parsedResult.metadata.description && (
<p className="mt-1 line-clamp-2 text-sm text-gray-600 dark:text-gray-400">
{parsedResult.metadata.description}
</p>
)}
<p className="mt-2 text-xs text-gray-500">
{t('modals.importSpec.version')}:{' '}
{parsedResult.metadata.version}
</p>
<div className="mt-3">
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
{t('modals.importSpec.baseUrl')}
</label>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="border-silver dark:border-silver/40 text-jet dark:text-bright-gray w-full rounded-lg border bg-white px-3 py-2 text-sm outline-hidden dark:bg-[#2C2C2C]"
placeholder={
parsedResult.metadata.base_url || 'https://api.example.com'
}
/>
</div>
</div>
<div className="flex items-center justify-between px-1">
<p className="text-jet dark:text-bright-gray text-sm font-medium">
{t('modals.importSpec.actionsFound', {
count: parsedResult.actions.length,
})}
</p>
<button
onClick={toggleAll}
className="text-purple-30 hover:text-violets-are-blue text-sm"
>
{selectedActions.size === parsedResult.actions.length
? t('modals.importSpec.deselectAll')
: t('modals.importSpec.selectAll')}
</button>
</div>
<div className="max-h-72 space-y-2 overflow-y-auto px-1">
{parsedResult.actions.map((action, index) => (
<label
key={index}
className="border-silver dark:border-silver/40 flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors hover:bg-[#F9F9F9] dark:hover:bg-[#28292D]"
>
<input
type="checkbox"
checked={selectedActions.has(index)}
onChange={() => toggleAction(index)}
className="text-purple-30 focus:ring-purple-30 mt-1 h-4 w-4 rounded border-gray-300"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[action.method.toUpperCase()] || METHOD_COLORS.GET}`}
>
{action.method.toUpperCase()}
</span>
<span className="text-jet dark:text-bright-gray truncate font-medium">
{action.name}
</span>
</div>
<p className="mt-1 truncate text-sm text-gray-500 dark:text-gray-400">
{action.url}
</p>
{action.description && (
<p className="mt-1 line-clamp-1 text-xs text-gray-400 dark:text-gray-500">
{action.description}
</p>
)}
</div>
</label>
))}
</div>
</div>
)}
<div className="mt-2 flex flex-row-reverse gap-2">
{!parsedResult ? (
<button
onClick={handleParse}
disabled={!file || loading}
className="bg-purple-30 hover:bg-violets-are-blue flex w-20 items-center justify-center gap-2 rounded-3xl px-5 py-2 text-sm text-white transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
{loading && <Spinner size="small" color="white" />}
{!loading && t('modals.importSpec.parse')}
</button>
) : (
<button
onClick={handleImport}
disabled={selectedActions.size === 0}
className="bg-purple-30 hover:bg-violets-are-blue rounded-3xl px-5 py-2 text-sm text-white transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
{t('modals.importSpec.import', { count: selectedActions.size })}
</button>
)}
<button
onClick={handleClose}
className="dark:text-light-gray cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
>
{t('modals.importSpec.cancel')}
</button>
</div>
</div>
</WrapperModal>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ export type ParameterGroupType = {
description: string;
value: string | number;
filled_by_llm: boolean;
required?: boolean;
};
};
};
@@ -57,6 +58,7 @@ export type UserToolType = {
description: string;
filled_by_llm: boolean;
value: string;
required?: boolean;
};
};
additionalProperties: boolean;
@@ -71,11 +73,24 @@ export type APIActionType = {
name: string;
url: string;
description: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
query_params: ParameterGroupType;
headers: ParameterGroupType;
body: ParameterGroupType;
active: boolean;
body_content_type?:
| 'application/json'
| 'application/x-www-form-urlencoded'
| 'multipart/form-data'
| 'text/plain'
| 'application/xml'
| 'application/octet-stream';
body_encoding_rules?: {
[key: string]: {
style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';
explode?: boolean;
};
};
};
export type APIToolType = {