mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
feat: user defined custom tools as part of workflow execution (#94)
* feat: add custom tools functionality * Show tools in nodes * integrate tool calling with pipeline engine
This commit is contained in:
parent
cc2d3e70d2
commit
3e55af9256
65 changed files with 5483 additions and 6673 deletions
498
ui/src/app/tools/[toolUuid]/page.tsx
Normal file
498
ui/src/app/tools/[toolUuid]/page.tsx
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Code, Globe, Loader2, Save } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
getToolApiV1ToolsToolUuidGet,
|
||||
updateToolApiV1ToolsToolUuidPut,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
|
||||
// Extended HttpApiConfig with parameters (until client types are regenerated)
|
||||
interface HttpApiConfigWithParams {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
credential_uuid?: string;
|
||||
parameters?: ToolParameter[];
|
||||
timeout_ms?: number;
|
||||
}
|
||||
import {
|
||||
CredentialSelector,
|
||||
type HttpMethod,
|
||||
HttpMethodSelector,
|
||||
KeyValueEditor,
|
||||
type KeyValueItem,
|
||||
ParameterEditor,
|
||||
type ToolParameter,
|
||||
} from "@/components/http";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function ToolDetailPage() {
|
||||
const { toolUuid } = useParams<{ toolUuid: string }>();
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [tool, setTool] = useState<ToolResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showCodeDialog, setShowCodeDialog] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [httpMethod, setHttpMethod] = useState<HttpMethod>("POST");
|
||||
const [url, setUrl] = useState("");
|
||||
const [credentialUuid, setCredentialUuid] = useState("");
|
||||
const [headers, setHeaders] = useState<KeyValueItem[]>([]);
|
||||
const [parameters, setParameters] = useState<ToolParameter[]>([]);
|
||||
const [timeoutMs, setTimeoutMs] = useState(5000);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
const fetchTool = useCallback(async () => {
|
||||
if (loading || !user || !toolUuid) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await getToolApiV1ToolsToolUuidGet({
|
||||
path: { tool_uuid: toolUuid },
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setTool(response.data);
|
||||
populateFormFromTool(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to fetch tool");
|
||||
console.error("Error fetching tool:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loading, user, toolUuid, getAccessToken]);
|
||||
|
||||
const populateFormFromTool = (tool: ToolResponse) => {
|
||||
setName(tool.name);
|
||||
setDescription(tool.description || "");
|
||||
|
||||
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
|
||||
if (config) {
|
||||
setHttpMethod((config.method as HttpMethod) || "POST");
|
||||
setUrl(config.url || "");
|
||||
setCredentialUuid(config.credential_uuid || "");
|
||||
setTimeoutMs(config.timeout_ms || 5000);
|
||||
|
||||
// Convert headers object to array
|
||||
if (config.headers) {
|
||||
setHeaders(
|
||||
Object.entries(config.headers).map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setHeaders([]);
|
||||
}
|
||||
|
||||
// Load parameters
|
||||
if (config.parameters && Array.isArray(config.parameters)) {
|
||||
setParameters(
|
||||
config.parameters.map((p: ToolParameter) => ({
|
||||
name: p.name || "",
|
||||
type: p.type || "string",
|
||||
description: p.description || "",
|
||||
required: p.required ?? true,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setParameters([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTool();
|
||||
}, [fetchTool]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate URL
|
||||
if (!url.trim()) {
|
||||
setError("URL is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate parameters have names
|
||||
const invalidParams = parameters.filter((p) => !p.name.trim());
|
||||
if (invalidParams.length > 0) {
|
||||
setError("All parameters must have a name");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSaveSuccess(false);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Convert headers array to object
|
||||
const headersObject: Record<string, string> = {};
|
||||
headers.filter((h) => h.key && h.value).forEach((h) => {
|
||||
headersObject[h.key] = h.value;
|
||||
});
|
||||
|
||||
// Filter out empty parameters
|
||||
const validParameters = parameters.filter((p) => p.name.trim());
|
||||
|
||||
// Build the request body (cast needed until client types are regenerated)
|
||||
const requestBody = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
definition: {
|
||||
schema_version: 1,
|
||||
type: "http_api",
|
||||
config: {
|
||||
method: httpMethod,
|
||||
url,
|
||||
credential_uuid: credentialUuid || undefined,
|
||||
headers:
|
||||
Object.keys(headersObject).length > 0
|
||||
? headersObject
|
||||
: undefined,
|
||||
parameters:
|
||||
validParameters.length > 0 ? validParameters : undefined,
|
||||
timeout_ms: timeoutMs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await updateToolApiV1ToolsToolUuidPut({
|
||||
path: { tool_uuid: toolUuid },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
body: requestBody as any,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setTool(response.data);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to save tool");
|
||||
console.error("Error saving tool:", err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getCodeSnippet = () => {
|
||||
if (!tool) return "";
|
||||
|
||||
const headersObj: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
headers.filter((h) => h.key && h.value).forEach((h) => {
|
||||
headersObj[h.key] = h.value;
|
||||
});
|
||||
|
||||
// Build example body from parameters
|
||||
const exampleBody: Record<string, unknown> = {};
|
||||
parameters.forEach((p) => {
|
||||
if (p.type === "number") {
|
||||
exampleBody[p.name] = 0;
|
||||
} else if (p.type === "boolean") {
|
||||
exampleBody[p.name] = true;
|
||||
} else {
|
||||
exampleBody[p.name] = `<${p.name}>`;
|
||||
}
|
||||
});
|
||||
|
||||
const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && parameters.length > 0;
|
||||
|
||||
return `// ${tool.name}
|
||||
// ${tool.description || "HTTP API Tool"}
|
||||
|
||||
const response = await fetch("${url}", {
|
||||
method: "${httpMethod}",
|
||||
headers: ${JSON.stringify(headersObj, null, 4)},${hasBody ? `
|
||||
body: JSON.stringify(${JSON.stringify(exampleBody, null, 4)}),` : ""}
|
||||
});
|
||||
|
||||
const data = await response.json();`;
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-96" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tool) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Tool not found</h1>
|
||||
<Button onClick={() => router.push("/tools")}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Tools
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/tools")}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: tool.icon_color || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
<Globe className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
HTTP API Tool
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCodeDialog(true)}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
View Code
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveSuccess && (
|
||||
<div className="mb-4 p-4 bg-green-500/10 border border-green-500/20 rounded-lg text-green-600">
|
||||
Tool saved successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tool Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the HTTP API endpoint and request settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="settings" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="auth">Authentication</TabsTrigger>
|
||||
<TabsTrigger value="parameters">Parameters</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tool Name</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Use a descriptive name, like "Get Weather using API" for a tool that fetches weather
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Book Appointment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Description</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Provide a description which makes it easy for LLM to understand what this tool does
|
||||
</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this tool do?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>HTTP Method</Label>
|
||||
<HttpMethodSelector
|
||||
value={httpMethod}
|
||||
onChange={setHttpMethod}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Timeout (ms)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeoutMs}
|
||||
onChange={(e) =>
|
||||
setTimeoutMs(parseInt(e.target.value) || 5000)
|
||||
}
|
||||
min={1000}
|
||||
max={30000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Endpoint URL</Label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/appointments"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="space-y-4 mt-4">
|
||||
<CredentialSelector
|
||||
value={credentialUuid}
|
||||
onChange={setCredentialUuid}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="parameters" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tool Parameters</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Define the parameters that the LLM will provide when calling this tool.
|
||||
These will be sent as JSON body for POST/PUT/PATCH or as URL query params for GET/DELETE.
|
||||
</Label>
|
||||
<ParameterEditor
|
||||
parameters={parameters}
|
||||
onChange={setParameters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>Custom Headers</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Add custom headers to include in the request (optional)
|
||||
</Label>
|
||||
<KeyValueEditor
|
||||
items={headers}
|
||||
onChange={setHeaders}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
addButtonText="Add Header"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code View Dialog */}
|
||||
<Dialog open={showCodeDialog} onOpenChange={setShowCodeDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Code Preview</DialogTitle>
|
||||
<DialogDescription>
|
||||
JavaScript code to make this API call
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-muted rounded-lg p-4 font-mono text-sm overflow-auto max-h-96">
|
||||
<pre>{getCodeSnippet()}</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
431
ui/src/app/tools/page.tsx
Normal file
431
ui/src/app/tools/page.tsx
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
"use client";
|
||||
|
||||
import { Globe, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
createToolApiV1ToolsPost,
|
||||
deleteToolApiV1ToolsToolUuidDelete,
|
||||
listToolsApiV1ToolsGet,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
type ToolCategory = "http_api" | "native" | "integration";
|
||||
|
||||
const TOOL_CATEGORIES: { value: ToolCategory; label: string; description: string; disabled?: boolean }[] = [
|
||||
{
|
||||
value: "http_api",
|
||||
label: "External HTTP API",
|
||||
description: "Make HTTP requests to external APIs",
|
||||
},
|
||||
{
|
||||
value: "native",
|
||||
label: "Native (Coming Soon)",
|
||||
description: "Built-in tools like call transfer, DTMF input",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "integration",
|
||||
label: "Integration (Coming Soon)",
|
||||
description: "Third-party integrations like Google Calendar",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function ToolsPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [tools, setTools] = useState<ToolResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [newToolName, setNewToolName] = useState("");
|
||||
const [newToolDescription, setNewToolDescription] = useState("");
|
||||
const [newToolCategory, setNewToolCategory] = useState<ToolCategory>("http_api");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
if (loading || !user) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await listToolsApiV1ToolsGet({
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setTools(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to fetch tools");
|
||||
console.error("Error fetching tools:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loading, user, getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
}, [fetchTools]);
|
||||
|
||||
const handleCreateTool = async () => {
|
||||
if (!newToolName.trim()) {
|
||||
setError("Please enter a name for the tool");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await createToolApiV1ToolsPost({
|
||||
body: {
|
||||
name: newToolName,
|
||||
description: newToolDescription || undefined,
|
||||
category: newToolCategory,
|
||||
icon: "globe",
|
||||
icon_color: "#3B82F6",
|
||||
definition: {
|
||||
schema_version: 1,
|
||||
type: newToolCategory,
|
||||
config: {
|
||||
method: "POST",
|
||||
url: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewToolName("");
|
||||
setNewToolDescription("");
|
||||
setNewToolCategory("http_api");
|
||||
// Navigate to the new tool's detail page
|
||||
router.push(`/tools/${response.data.tool_uuid}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to create tool");
|
||||
console.error("Error creating tool:", err);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTool = async (toolUuid: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Are you sure you want to archive this tool?")) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
await deleteToolApiV1ToolsToolUuidDelete({
|
||||
path: {
|
||||
tool_uuid: toolUuid,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchTools();
|
||||
} catch (err) {
|
||||
setError("Failed to delete tool");
|
||||
console.error("Error deleting tool:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTools = tools.filter(
|
||||
(tool) =>
|
||||
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getCategoryBadge = (category: string) => {
|
||||
switch (category) {
|
||||
case "http_api":
|
||||
return <Badge variant="default">HTTP API</Badge>;
|
||||
case "native":
|
||||
return <Badge variant="secondary">Native</Badge>;
|
||||
case "integration":
|
||||
return <Badge variant="outline">Integration</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{category}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge className="bg-green-500">Active</Badge>;
|
||||
case "draft":
|
||||
return <Badge variant="secondary">Draft</Badge>;
|
||||
case "archived":
|
||||
return <Badge variant="destructive">Archived</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-96" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Tools</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage reusable HTTP API tools that can be used across your workflows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Your Tools</CardTitle>
|
||||
<CardDescription>
|
||||
Create and manage HTTP API tools for your organization
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Tool
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredTools.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Globe className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchQuery
|
||||
? "No tools match your search"
|
||||
: "No tools found"}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
Create Your First Tool
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTools.map((tool) => (
|
||||
<div
|
||||
key={tool.tool_uuid}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() =>
|
||||
router.push(`/tools/${tool.tool_uuid}`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor:
|
||||
tool.icon_color || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
<Globe className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{tool.name}
|
||||
</span>
|
||||
{getCategoryBadge(tool.category)}
|
||||
{getStatusBadge(tool.status)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) =>
|
||||
handleDeleteTool(tool.tool_uuid, e)
|
||||
}
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Tool Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Tool</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new tool that can be used in your workflows.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tool Type</Label>
|
||||
<Select
|
||||
value={newToolCategory}
|
||||
onValueChange={(v) => setNewToolCategory(v as ToolCategory)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TOOL_CATEGORIES.map((category) => (
|
||||
<SelectItem
|
||||
key={category.value}
|
||||
value={category.value}
|
||||
disabled={category.disabled}
|
||||
>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TOOL_CATEGORIES.find(c => c.value === newToolCategory)?.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Tool Name</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Use a descriptive name, like "Get Weather using API" for a tool that fetches weather
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newToolName}
|
||||
onChange={(e) => setNewToolName(e.target.value)}
|
||||
placeholder="e.g., Book Appointment, Check Inventory"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description (Optional)</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Provide a description which makes it easy for LLM to understand what this tool does
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={newToolDescription}
|
||||
onChange={(e) => setNewToolDescription(e.target.value)}
|
||||
placeholder="What does this tool do?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateTool} disabled={isCreating}>
|
||||
{isCreating ? "Creating..." : "Create Tool"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -221,7 +221,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
</ReactFlow>
|
||||
|
||||
{/* Bottom-left controls - horizontal layout with custom buttons */}
|
||||
<div className="absolute bottom-12 left-8 z-[1000] flex gap-2">
|
||||
<div className="absolute bottom-12 left-8 z-10 flex gap-2">
|
||||
<TooltipProvider>
|
||||
{/* Zoom In */}
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export default function WorkflowRunPage() {
|
|||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Back to Agent
|
||||
Customize Agent
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -144,6 +144,18 @@ export type CreateTestSessionRequest = {
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for creating a tool.
|
||||
*/
|
||||
export type CreateToolRequest = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
category?: string;
|
||||
icon?: string | null;
|
||||
icon_color?: string | null;
|
||||
definition: ToolDefinition;
|
||||
};
|
||||
|
||||
export type CreateWorkflowRequest = {
|
||||
name: string;
|
||||
workflow_definition: {
|
||||
|
|
@ -174,6 +186,14 @@ export type CreateWorkflowTemplateRequest = {
|
|||
activity_description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for the user who created a tool.
|
||||
*/
|
||||
export type CreatedByResponse = {
|
||||
id: number;
|
||||
provider_id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for a webhook credential (never includes sensitive data).
|
||||
*/
|
||||
|
|
@ -309,6 +329,52 @@ export type HttpValidationError = {
|
|||
detail?: Array<ValidationError>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for HTTP API tools.
|
||||
*/
|
||||
export type HttpApiConfig = {
|
||||
/**
|
||||
* HTTP method (GET, POST, PUT, PATCH, DELETE)
|
||||
*/
|
||||
method: string;
|
||||
/**
|
||||
* Target URL (supports {{variable}} placeholders)
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Static headers to include
|
||||
*/
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
/**
|
||||
* Reference to ExternalCredentialModel for auth
|
||||
*/
|
||||
credential_uuid?: string | null;
|
||||
/**
|
||||
* Request body with {{variable}} placeholders
|
||||
*/
|
||||
body_template?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Request timeout in milliseconds
|
||||
*/
|
||||
timeout_ms?: number | null;
|
||||
/**
|
||||
* Retry configuration
|
||||
*/
|
||||
retry_config?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* JSONPath mappings for response extraction
|
||||
*/
|
||||
response_mapping?: {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request payload for superadmin impersonation.
|
||||
*
|
||||
|
|
@ -500,6 +566,44 @@ export type TestSessionResponse = {
|
|||
completed_at: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool definition schema.
|
||||
*/
|
||||
export type ToolDefinition = {
|
||||
/**
|
||||
* Schema version for compatibility
|
||||
*/
|
||||
schema_version?: number;
|
||||
/**
|
||||
* Tool type (http_api)
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Tool configuration
|
||||
*/
|
||||
config: HttpApiConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for a tool.
|
||||
*/
|
||||
export type ToolResponse = {
|
||||
id: number;
|
||||
tool_uuid: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
icon: string | null;
|
||||
icon_color: string | null;
|
||||
status: string;
|
||||
definition: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
created_by?: CreatedByResponse | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request model for triggering a call via API
|
||||
*/
|
||||
|
|
@ -566,6 +670,18 @@ export type UpdateIntegrationRequest = {
|
|||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for updating a tool.
|
||||
*/
|
||||
export type UpdateToolRequest = {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
icon_color?: string | null;
|
||||
definition?: ToolDefinition | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateWorkflowRequest = {
|
||||
name: string;
|
||||
workflow_definition?: {
|
||||
|
|
@ -2347,6 +2463,177 @@ export type UpdateCredentialApiV1CredentialsCredentialUuidPutResponses = {
|
|||
|
||||
export type UpdateCredentialApiV1CredentialsCredentialUuidPutResponse = UpdateCredentialApiV1CredentialsCredentialUuidPutResponses[keyof UpdateCredentialApiV1CredentialsCredentialUuidPutResponses];
|
||||
|
||||
export type ListToolsApiV1ToolsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
status?: string | null;
|
||||
category?: string | null;
|
||||
};
|
||||
url: '/api/v1/tools/';
|
||||
};
|
||||
|
||||
export type ListToolsApiV1ToolsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ListToolsApiV1ToolsGetError = ListToolsApiV1ToolsGetErrors[keyof ListToolsApiV1ToolsGetErrors];
|
||||
|
||||
export type ListToolsApiV1ToolsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: Array<ToolResponse>;
|
||||
};
|
||||
|
||||
export type ListToolsApiV1ToolsGetResponse = ListToolsApiV1ToolsGetResponses[keyof ListToolsApiV1ToolsGetResponses];
|
||||
|
||||
export type CreateToolApiV1ToolsPostData = {
|
||||
body: CreateToolRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/tools/';
|
||||
};
|
||||
|
||||
export type CreateToolApiV1ToolsPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateToolApiV1ToolsPostError = CreateToolApiV1ToolsPostErrors[keyof CreateToolApiV1ToolsPostErrors];
|
||||
|
||||
export type CreateToolApiV1ToolsPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: ToolResponse;
|
||||
};
|
||||
|
||||
export type CreateToolApiV1ToolsPostResponse = CreateToolApiV1ToolsPostResponses[keyof CreateToolApiV1ToolsPostResponses];
|
||||
|
||||
export type DeleteToolApiV1ToolsToolUuidDeleteData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
tool_uuid: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/tools/{tool_uuid}';
|
||||
};
|
||||
|
||||
export type DeleteToolApiV1ToolsToolUuidDeleteErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type DeleteToolApiV1ToolsToolUuidDeleteError = DeleteToolApiV1ToolsToolUuidDeleteErrors[keyof DeleteToolApiV1ToolsToolUuidDeleteErrors];
|
||||
|
||||
export type DeleteToolApiV1ToolsToolUuidDeleteResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteToolApiV1ToolsToolUuidDeleteResponse = DeleteToolApiV1ToolsToolUuidDeleteResponses[keyof DeleteToolApiV1ToolsToolUuidDeleteResponses];
|
||||
|
||||
export type GetToolApiV1ToolsToolUuidGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
tool_uuid: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/tools/{tool_uuid}';
|
||||
};
|
||||
|
||||
export type GetToolApiV1ToolsToolUuidGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetToolApiV1ToolsToolUuidGetError = GetToolApiV1ToolsToolUuidGetErrors[keyof GetToolApiV1ToolsToolUuidGetErrors];
|
||||
|
||||
export type GetToolApiV1ToolsToolUuidGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: ToolResponse;
|
||||
};
|
||||
|
||||
export type GetToolApiV1ToolsToolUuidGetResponse = GetToolApiV1ToolsToolUuidGetResponses[keyof GetToolApiV1ToolsToolUuidGetResponses];
|
||||
|
||||
export type UpdateToolApiV1ToolsToolUuidPutData = {
|
||||
body: UpdateToolRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
tool_uuid: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/tools/{tool_uuid}';
|
||||
};
|
||||
|
||||
export type UpdateToolApiV1ToolsToolUuidPutErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateToolApiV1ToolsToolUuidPutError = UpdateToolApiV1ToolsToolUuidPutErrors[keyof UpdateToolApiV1ToolsToolUuidPutErrors];
|
||||
|
||||
export type UpdateToolApiV1ToolsToolUuidPutResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: ToolResponse;
|
||||
};
|
||||
|
||||
export type UpdateToolApiV1ToolsToolUuidPutResponse = UpdateToolApiV1ToolsToolUuidPutResponses[keyof UpdateToolApiV1ToolsToolUuidPutResponses];
|
||||
|
||||
export type GetIntegrationsApiV1IntegrationGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
64
ui/src/components/flow/ToolBadges.tsx
Normal file
64
ui/src/components/flow/ToolBadges.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { listToolsApiV1ToolsGet } from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface ToolBadgesProps {
|
||||
toolUuids: string[];
|
||||
}
|
||||
|
||||
export function ToolBadges({ toolUuids }: ToolBadgesProps) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const [tools, setTools] = useState<ToolResponse[]>([]);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await listToolsApiV1ToolsGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.data) {
|
||||
setTools(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tools:", error);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toolUuids.length > 0) {
|
||||
fetchTools();
|
||||
}
|
||||
}, [toolUuids.length, fetchTools]);
|
||||
|
||||
const selectedTools = tools.filter((tool) => toolUuids.includes(tool.tool_uuid));
|
||||
|
||||
if (selectedTools.length === 0 && toolUuids.length > 0) {
|
||||
// Still loading or tools not found
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Loading...
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTools.map((tool) => (
|
||||
<Badge
|
||||
key={tool.tool_uuid}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{tool.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
ui/src/components/flow/ToolSelector.tsx
Normal file
161
ui/src/components/flow/ToolSelector.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink, Globe, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { listToolsApiV1ToolsGet } from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface ToolSelectorProps {
|
||||
value: string[];
|
||||
onChange: (uuids: string[]) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function ToolSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label = "Tools",
|
||||
description = "Select tools that the agent can use during the conversation.",
|
||||
showLabel = true,
|
||||
}: ToolSelectorProps) {
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
const [tools, setTools] = useState<ToolResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await listToolsApiV1ToolsGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
query: { status: "active" },
|
||||
});
|
||||
if (response.error) {
|
||||
console.error("Failed to fetch tools:", response.error);
|
||||
setTools([]);
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
setTools(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tools:", error);
|
||||
setTools([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
}, [fetchTools]);
|
||||
|
||||
const handleToggle = (toolUuid: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onChange([...value, toolUuid]);
|
||||
} else {
|
||||
onChange(value.filter((id) => id !== toolUuid));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{showLabel && (
|
||||
<>
|
||||
<Label>{label}</Label>
|
||||
{description && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</Label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 p-3 border rounded-md">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Loading tools...</span>
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="p-4 border rounded-md text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
No tools available.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/tools" target="_blank">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Create a Tool
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md divide-y">
|
||||
{tools.map((tool) => {
|
||||
const isSelected = value.includes(tool.tool_uuid);
|
||||
return (
|
||||
<label
|
||||
key={tool.tool_uuid}
|
||||
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50 ${
|
||||
disabled ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
handleToggle(tool.tool_uuid, checked === true);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: tool.icon_color || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
<Globe className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.description && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div className="p-2 bg-muted/30">
|
||||
<Link
|
||||
href="/tools"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Manage Tools
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{value.length} tool{value.length !== 1 ? "s" : ""} selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { Edit, Headset, PlusIcon,Trash2Icon } from "lucide-react";
|
||||
import { Edit, Headset, PlusIcon, Trash2Icon, Wrench } from "lucide-react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { ExtractionVariable,FlowNodeData } from "@/components/flow/types";
|
||||
import { ToolBadges } from "@/components/flow/ToolBadges";
|
||||
import { ToolSelector } from "@/components/flow/ToolSelector";
|
||||
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -30,6 +32,8 @@ interface AgentNodeEditFormProps {
|
|||
setVariables: (vars: ExtractionVariable[]) => void;
|
||||
addGlobalPrompt: boolean;
|
||||
setAddGlobalPrompt: (value: boolean) => void;
|
||||
toolUuids: string[];
|
||||
setToolUuids: (value: string[]) => void;
|
||||
}
|
||||
|
||||
interface AgentNodeProps extends NodeProps {
|
||||
|
|
@ -50,6 +54,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? "");
|
||||
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
|
||||
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
|
||||
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
|
||||
|
||||
const handleSave = async () => {
|
||||
handleSaveNodeData({
|
||||
|
|
@ -61,6 +66,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
extraction_prompt: extractionPrompt,
|
||||
extraction_variables: variables,
|
||||
add_global_prompt: addGlobalPrompt,
|
||||
tool_uuids: toolUuids.length > 0 ? toolUuids : undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
// Save the workflow after updating node data with a small delay to ensure state is updated
|
||||
|
|
@ -79,6 +85,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
setExtractionPrompt(data.extraction_prompt ?? "");
|
||||
setVariables(data.extraction_variables ?? []);
|
||||
setAddGlobalPrompt(data.add_global_prompt ?? true);
|
||||
setToolUuids(data.tool_uuids ?? []);
|
||||
}
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
|
@ -93,6 +100,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
setExtractionPrompt(data.extraction_prompt ?? "");
|
||||
setVariables(data.extraction_variables ?? []);
|
||||
setAddGlobalPrompt(data.add_global_prompt ?? true);
|
||||
setToolUuids(data.tool_uuids ?? []);
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
|
|
@ -114,6 +122,15 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
|
||||
{data.prompt || 'No prompt configured'}
|
||||
</p>
|
||||
{data.tool_uuids && data.tool_uuids.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span>Tools:</span>
|
||||
</div>
|
||||
<ToolBadges toolUuids={data.tool_uuids} />
|
||||
</div>
|
||||
)}
|
||||
</NodeContent>
|
||||
|
||||
<NodeToolbar isVisible={selected} position={Position.Right}>
|
||||
|
|
@ -151,6 +168,8 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
setVariables={setVariables}
|
||||
addGlobalPrompt={addGlobalPrompt}
|
||||
setAddGlobalPrompt={setAddGlobalPrompt}
|
||||
toolUuids={toolUuids}
|
||||
setToolUuids={setToolUuids}
|
||||
/>
|
||||
)}
|
||||
</NodeEditDialog>
|
||||
|
|
@ -173,6 +192,8 @@ const AgentNodeEditForm = ({
|
|||
setVariables,
|
||||
addGlobalPrompt,
|
||||
setAddGlobalPrompt,
|
||||
toolUuids,
|
||||
setToolUuids,
|
||||
}: AgentNodeEditFormProps) => {
|
||||
const handleVariableNameChange = (idx: number, value: string) => {
|
||||
const newVars = [...variables];
|
||||
|
|
@ -307,6 +328,15 @@ const AgentNodeEditForm = ({
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Section */}
|
||||
<div className="pt-4 border-t mt-4">
|
||||
<ToolSelector
|
||||
value={toolUuids}
|
||||
onChange={setToolUuids}
|
||||
description="Select tools that the agent can invoke during this conversation step."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { Edit, Play } from "lucide-react";
|
||||
import { Edit, Play, Wrench } from "lucide-react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { ToolBadges } from "@/components/flow/ToolBadges";
|
||||
import { ToolSelector } from "@/components/flow/ToolSelector";
|
||||
import { FlowNodeData } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -31,6 +33,8 @@ interface StartCallEditFormProps {
|
|||
setDelayedStart: (value: boolean) => void;
|
||||
delayedStartDuration: number;
|
||||
setDelayedStartDuration: (value: number) => void;
|
||||
toolUuids: string[];
|
||||
setToolUuids: (value: string[]) => void;
|
||||
}
|
||||
|
||||
interface StartCallNodeProps extends NodeProps {
|
||||
|
|
@ -52,6 +56,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
const [detectVoicemail, setDetectVoicemail] = useState(data.detect_voicemail ?? false);
|
||||
const [delayedStart, setDelayedStart] = useState(data.delayed_start ?? false);
|
||||
const [delayedStartDuration, setDelayedStartDuration] = useState(data.delayed_start_duration ?? 2);
|
||||
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
|
||||
|
||||
const handleSave = async () => {
|
||||
handleSaveNodeData({
|
||||
|
|
@ -62,7 +67,8 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
add_global_prompt: addGlobalPrompt,
|
||||
detect_voicemail: detectVoicemail,
|
||||
delayed_start: delayedStart,
|
||||
delayed_start_duration: delayedStart ? delayedStartDuration : undefined
|
||||
delayed_start_duration: delayedStart ? delayedStartDuration : undefined,
|
||||
tool_uuids: toolUuids.length > 0 ? toolUuids : undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
// Save the workflow after updating node data with a small delay to ensure state is updated
|
||||
|
|
@ -81,6 +87,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
setDetectVoicemail(data.detect_voicemail ?? false);
|
||||
setDelayedStart(data.delayed_start ?? false);
|
||||
setDelayedStartDuration(data.delayed_start_duration ?? 3);
|
||||
setToolUuids(data.tool_uuids ?? []);
|
||||
}
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
|
@ -95,6 +102,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
setDetectVoicemail(data.detect_voicemail ?? false);
|
||||
setDelayedStart(data.delayed_start ?? false);
|
||||
setDelayedStartDuration(data.delayed_start_duration ?? 3);
|
||||
setToolUuids(data.tool_uuids ?? []);
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
|
|
@ -115,6 +123,15 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
|
||||
{data.prompt || 'No prompt configured'}
|
||||
</p>
|
||||
{data.tool_uuids && data.tool_uuids.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span>Tools:</span>
|
||||
</div>
|
||||
<ToolBadges toolUuids={data.tool_uuids} />
|
||||
</div>
|
||||
)}
|
||||
</NodeContent>
|
||||
|
||||
<NodeToolbar isVisible={selected} position={Position.Right}>
|
||||
|
|
@ -147,6 +164,8 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
setDelayedStart={setDelayedStart}
|
||||
delayedStartDuration={delayedStartDuration}
|
||||
setDelayedStartDuration={setDelayedStartDuration}
|
||||
toolUuids={toolUuids}
|
||||
setToolUuids={setToolUuids}
|
||||
/>
|
||||
)}
|
||||
</NodeEditDialog>
|
||||
|
|
@ -168,7 +187,9 @@ const StartCallEditForm = ({
|
|||
delayedStart,
|
||||
setDelayedStart,
|
||||
delayedStartDuration,
|
||||
setDelayedStartDuration
|
||||
setDelayedStartDuration,
|
||||
toolUuids,
|
||||
setToolUuids,
|
||||
}: StartCallEditFormProps) => {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -258,6 +279,15 @@ const StartCallEditForm = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tools Section */}
|
||||
<div className="pt-4 border-t mt-4">
|
||||
<ToolSelector
|
||||
value={toolUuids}
|
||||
onChange={setToolUuids}
|
||||
description="Select tools that the agent can invoke during this conversation step."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,22 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { AlertCircle, Circle, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { Circle, Edit, Link2, Trash2Icon } from "lucide-react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import {
|
||||
createCredentialApiV1CredentialsPost,
|
||||
listCredentialsApiV1CredentialsGet,
|
||||
} from "@/client";
|
||||
import { CredentialResponse, WebhookCredentialType } from "@/client/types.gen";
|
||||
import { FlowNodeData } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
CredentialSelector,
|
||||
type HttpMethod,
|
||||
HttpMethodSelector,
|
||||
KeyValueEditor,
|
||||
type KeyValueItem,
|
||||
} from "@/components/http";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { JsonEditor, validateJson } from "@/components/ui/json-editor";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import { NodeContent } from "./common/NodeContent";
|
||||
import { NodeEditDialog } from "./common/NodeEditDialog";
|
||||
|
|
@ -40,17 +26,9 @@ interface WebhookNodeProps extends NodeProps {
|
|||
data: FlowNodeData;
|
||||
}
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface CustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
|
||||
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
|
||||
const { saveWorkflow } = useWorkflow();
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState(data.name || "Webhook");
|
||||
|
|
@ -58,41 +36,13 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
|
|||
const [httpMethod, setHttpMethod] = useState<HttpMethod>(data.http_method || "POST");
|
||||
const [endpointUrl, setEndpointUrl] = useState(data.endpoint_url || "");
|
||||
const [credentialUuid, setCredentialUuid] = useState(data.credential_uuid || "");
|
||||
const [customHeaders, setCustomHeaders] = useState<CustomHeader[]>(
|
||||
const [customHeaders, setCustomHeaders] = useState<KeyValueItem[]>(
|
||||
data.custom_headers || []
|
||||
);
|
||||
const [payloadTemplate, setPayloadTemplate] = useState(
|
||||
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
|
||||
);
|
||||
|
||||
// Credentials state
|
||||
const [credentials, setCredentials] = useState<CredentialResponse[]>([]);
|
||||
const [credentialsLoading, setCredentialsLoading] = useState(false);
|
||||
|
||||
// Fetch credentials when dialog opens
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setCredentialsLoading(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await listCredentialsApiV1CredentialsGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.error) {
|
||||
console.error("Failed to fetch credentials:", response.error);
|
||||
setCredentials([]);
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
setCredentials(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch credentials:", error);
|
||||
setCredentials([]);
|
||||
} finally {
|
||||
setCredentialsLoading(false);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
// Validation state - only shown on save attempt
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [endpointError, setEndpointError] = useState<string | null>(null);
|
||||
|
|
@ -143,8 +93,6 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
|
|||
// Clear any previous errors
|
||||
setJsonError(null);
|
||||
setEndpointError(null);
|
||||
// Fetch credentials when dialog opens
|
||||
fetchCredentials();
|
||||
}
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
|
@ -233,10 +181,6 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
|
|||
setEndpointUrl={setEndpointUrl}
|
||||
credentialUuid={credentialUuid}
|
||||
setCredentialUuid={setCredentialUuid}
|
||||
credentials={credentials}
|
||||
credentialsLoading={credentialsLoading}
|
||||
onRefreshCredentials={fetchCredentials}
|
||||
getAccessToken={getAccessToken}
|
||||
customHeaders={customHeaders}
|
||||
setCustomHeaders={setCustomHeaders}
|
||||
payloadTemplate={payloadTemplate}
|
||||
|
|
@ -259,16 +203,23 @@ interface WebhookNodeEditFormProps {
|
|||
setEndpointUrl: (value: string) => void;
|
||||
credentialUuid: string;
|
||||
setCredentialUuid: (value: string) => void;
|
||||
credentials: CredentialResponse[];
|
||||
credentialsLoading: boolean;
|
||||
onRefreshCredentials: () => Promise<void>;
|
||||
getAccessToken: () => Promise<string>;
|
||||
customHeaders: CustomHeader[];
|
||||
setCustomHeaders: (value: CustomHeader[]) => void;
|
||||
customHeaders: KeyValueItem[];
|
||||
setCustomHeaders: (value: KeyValueItem[]) => void;
|
||||
payloadTemplate: string;
|
||||
setPayloadTemplate: (value: string) => void;
|
||||
}
|
||||
|
||||
const availableVariables = [
|
||||
{ name: "workflow_run_id", description: "Unique ID of the workflow run" },
|
||||
{ name: "workflow_id", description: "ID of the workflow" },
|
||||
{ name: "workflow_name", description: "Name of the workflow" },
|
||||
{ name: "initial_context.*", description: "Initial context variables" },
|
||||
{ name: "gathered_context.*", description: "Extracted variables" },
|
||||
{ name: "cost_info.call_duration_seconds", description: "Call duration" },
|
||||
{ name: "recording_url", description: "Call recording URL" },
|
||||
{ name: "transcript_url", description: "Transcript URL" },
|
||||
];
|
||||
|
||||
const WebhookNodeEditForm = ({
|
||||
name,
|
||||
setName,
|
||||
|
|
@ -280,130 +231,11 @@ const WebhookNodeEditForm = ({
|
|||
setEndpointUrl,
|
||||
credentialUuid,
|
||||
setCredentialUuid,
|
||||
credentials,
|
||||
credentialsLoading,
|
||||
onRefreshCredentials,
|
||||
getAccessToken,
|
||||
customHeaders,
|
||||
setCustomHeaders,
|
||||
payloadTemplate,
|
||||
setPayloadTemplate,
|
||||
}: WebhookNodeEditFormProps) => {
|
||||
// Add Credential Dialog state
|
||||
const [isAddCredentialOpen, setIsAddCredentialOpen] = useState(false);
|
||||
const [newCredName, setNewCredName] = useState("");
|
||||
const [newCredDescription, setNewCredDescription] = useState("");
|
||||
const [newCredType, setNewCredType] = useState<WebhookCredentialType>("bearer_token");
|
||||
const [newCredData, setNewCredData] = useState<Record<string, string>>({});
|
||||
const [isCreatingCredential, setIsCreatingCredential] = useState(false);
|
||||
const [credentialError, setCredentialError] = useState<string | null>(null);
|
||||
|
||||
const handleCreateCredential = async () => {
|
||||
if (!newCredName.trim()) return;
|
||||
|
||||
setIsCreatingCredential(true);
|
||||
setCredentialError(null);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await createCredentialApiV1CredentialsPost({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: {
|
||||
name: newCredName,
|
||||
description: newCredDescription || undefined,
|
||||
credential_type: newCredType,
|
||||
credential_data: newCredData,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
const errorDetail = (response.error as { detail?: string })?.detail
|
||||
|| "Failed to create credential";
|
||||
setCredentialError(errorDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
// Refresh credentials list
|
||||
await onRefreshCredentials();
|
||||
// Select the newly created credential
|
||||
setCredentialUuid(response.data.uuid);
|
||||
// Close dialog and reset form
|
||||
setIsAddCredentialOpen(false);
|
||||
setNewCredName("");
|
||||
setNewCredDescription("");
|
||||
setNewCredType("bearer_token");
|
||||
setNewCredData({});
|
||||
setCredentialError(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create credential:", error);
|
||||
setCredentialError(
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsCreatingCredential(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCredentialDialogChange = (open: boolean) => {
|
||||
setIsAddCredentialOpen(open);
|
||||
if (!open) {
|
||||
// Reset error when closing dialog
|
||||
setCredentialError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getCredentialDataFields = (type: WebhookCredentialType) => {
|
||||
switch (type) {
|
||||
case "api_key":
|
||||
return [
|
||||
{ key: "header_name", label: "Header Name", placeholder: "X-API-Key" },
|
||||
{ key: "api_key", label: "API Key", placeholder: "your-api-key", isSecret: true },
|
||||
];
|
||||
case "bearer_token":
|
||||
return [
|
||||
{ key: "token", label: "Token", placeholder: "your-bearer-token", isSecret: true },
|
||||
];
|
||||
case "basic_auth":
|
||||
return [
|
||||
{ key: "username", label: "Username", placeholder: "username" },
|
||||
{ key: "password", label: "Password", placeholder: "password", isSecret: true },
|
||||
];
|
||||
case "custom_header":
|
||||
return [
|
||||
{ key: "header_name", label: "Header Name", placeholder: "X-Custom-Header" },
|
||||
{ key: "header_value", label: "Header Value", placeholder: "header-value", isSecret: true },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
setCustomHeaders([...customHeaders, { key: "", value: "" }]);
|
||||
};
|
||||
|
||||
const updateHeader = (index: number, field: "key" | "value", value: string) => {
|
||||
const newHeaders = [...customHeaders];
|
||||
newHeaders[index] = { ...newHeaders[index], [field]: value };
|
||||
setCustomHeaders(newHeaders);
|
||||
};
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
setCustomHeaders(customHeaders.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const availableVariables = [
|
||||
{ name: "workflow_run_id", description: "Unique ID of the workflow run" },
|
||||
{ name: "workflow_id", description: "ID of the workflow" },
|
||||
{ name: "workflow_name", description: "Name of the workflow" },
|
||||
{ name: "initial_context.*", description: "Initial context variables" },
|
||||
{ name: "gathered_context.*", description: "Extracted variables" },
|
||||
{ name: "cost_info.call_duration_seconds", description: "Call duration" },
|
||||
{ name: "recording_url", description: "Call recording URL" },
|
||||
{ name: "transcript_url", description: "Transcript URL" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
|
|
@ -432,18 +264,10 @@ const WebhookNodeEditForm = ({
|
|||
|
||||
<div className="grid gap-2">
|
||||
<Label>HTTP Method</Label>
|
||||
<Select value={httpMethod} onValueChange={(v) => setHttpMethod(v as HttpMethod)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HttpMethodSelector
|
||||
value={httpMethod}
|
||||
onChange={setHttpMethod}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -460,154 +284,10 @@ const WebhookNodeEditForm = ({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Credential</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Select a credential for authentication, or leave empty for no auth.
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={credentialUuid || "none"}
|
||||
onValueChange={(v) => setCredentialUuid(v === "none" ? "" : v)}
|
||||
disabled={credentialsLoading}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
{credentialsLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="No authentication" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No authentication</SelectItem>
|
||||
{credentials.map((cred) => (
|
||||
<SelectItem key={cred.uuid} value={cred.uuid}>
|
||||
{cred.name} ({cred.credential_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsAddCredentialOpen(true)}
|
||||
title="Add new credential"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentials.length === 0 && !credentialsLoading && (
|
||||
<div className="p-3 border rounded-md bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No credentials found. Click the + button to create one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Credential Dialog */}
|
||||
<Dialog open={isAddCredentialOpen} onOpenChange={handleAddCredentialDialogChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Credential</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new credential for webhook authentication.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Error display */}
|
||||
{credentialError && (
|
||||
<div className="flex items-start gap-2 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{credentialError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cred-name">Name *</Label>
|
||||
<Input
|
||||
id="cred-name"
|
||||
value={newCredName}
|
||||
onChange={(e) => setNewCredName(e.target.value)}
|
||||
placeholder="My API Key"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cred-description">Description</Label>
|
||||
<Input
|
||||
id="cred-description"
|
||||
value={newCredDescription}
|
||||
onChange={(e) => setNewCredDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Credential Type</Label>
|
||||
<Select
|
||||
value={newCredType}
|
||||
onValueChange={(v) => {
|
||||
setNewCredType(v as WebhookCredentialType);
|
||||
setNewCredData({});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bearer_token">Bearer Token</SelectItem>
|
||||
<SelectItem value="api_key">API Key</SelectItem>
|
||||
<SelectItem value="basic_auth">Basic Auth</SelectItem>
|
||||
<SelectItem value="custom_header">Custom Header</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{getCredentialDataFields(newCredType).map((field) => (
|
||||
<div key={field.key} className="grid gap-2">
|
||||
<Label htmlFor={`cred-${field.key}`}>{field.label}</Label>
|
||||
<Input
|
||||
id={`cred-${field.key}`}
|
||||
type={field.isSecret ? "password" : "text"}
|
||||
value={newCredData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
setNewCredData((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAddCredentialOpen(false)}
|
||||
disabled={isCreatingCredential}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateCredential}
|
||||
disabled={!newCredName.trim() || isCreatingCredential}
|
||||
>
|
||||
{isCreatingCredential ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CredentialSelector
|
||||
value={credentialUuid}
|
||||
onChange={setCredentialUuid}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="headers" className="space-y-4 mt-4">
|
||||
|
|
@ -616,34 +296,13 @@ const WebhookNodeEditForm = ({
|
|||
<Label className="text-xs text-muted-foreground">
|
||||
Add custom headers to include in the webhook request.
|
||||
</Label>
|
||||
|
||||
{customHeaders.map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Header name"
|
||||
value={header.key}
|
||||
onChange={(e) => updateHeader(index, "key", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header value"
|
||||
value={header.value}
|
||||
onChange={(e) => updateHeader(index, "value", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removeHeader(index)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={addHeader} className="w-fit">
|
||||
<PlusIcon className="h-4 w-4 mr-1" /> Add Header
|
||||
</Button>
|
||||
<KeyValueEditor
|
||||
items={customHeaders}
|
||||
onChange={setCustomHeaders}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
addButtonText="Add Header"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export type FlowNodeData = {
|
|||
max_retries: number;
|
||||
retry_delay_seconds: number;
|
||||
};
|
||||
// Tools - array of tool UUIDs that can be invoked by this node
|
||||
tool_uuids?: string[];
|
||||
}
|
||||
|
||||
export type FlowNode = {
|
||||
|
|
|
|||
242
ui/src/components/http/create-credential-dialog.tsx
Normal file
242
ui/src/components/http/create-credential-dialog.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { createCredentialApiV1CredentialsPost } from "@/client";
|
||||
import { CredentialResponse, WebhookCredentialType } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface CreateCredentialDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated?: (credential: CredentialResponse) => void;
|
||||
}
|
||||
|
||||
interface CredentialField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
const getCredentialDataFields = (type: WebhookCredentialType): CredentialField[] => {
|
||||
switch (type) {
|
||||
case "api_key":
|
||||
return [
|
||||
{ key: "header_name", label: "Header Name", placeholder: "X-API-Key" },
|
||||
{ key: "api_key", label: "API Key", placeholder: "your-api-key", isSecret: true },
|
||||
];
|
||||
case "bearer_token":
|
||||
return [
|
||||
{ key: "token", label: "Token", placeholder: "your-bearer-token", isSecret: true },
|
||||
];
|
||||
case "basic_auth":
|
||||
return [
|
||||
{ key: "username", label: "Username", placeholder: "username" },
|
||||
{ key: "password", label: "Password", placeholder: "password", isSecret: true },
|
||||
];
|
||||
case "custom_header":
|
||||
return [
|
||||
{ key: "header_name", label: "Header Name", placeholder: "X-Custom-Header" },
|
||||
{ key: "header_value", label: "Header Value", placeholder: "header-value", isSecret: true },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export function CreateCredentialDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: CreateCredentialDialogProps) {
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [credentialType, setCredentialType] = useState<WebhookCredentialType>("bearer_token");
|
||||
const [credentialData, setCredentialData] = useState<Record<string, string>>({});
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await createCredentialApiV1CredentialsPost({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
credential_type: credentialType,
|
||||
credential_data: credentialData,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
const errorDetail = (response.error as { detail?: string })?.detail
|
||||
|| "Failed to create credential";
|
||||
setError(errorDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
onCreated?.(response.data);
|
||||
handleClose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create credential:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "An unexpected error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
// Reset form
|
||||
setName("");
|
||||
setDescription("");
|
||||
setCredentialType("bearer_token");
|
||||
setCredentialData({});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setError(null);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const fields = getCredentialDataFields(credentialType);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Credential</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new credential for authentication.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cred-name">Name *</Label>
|
||||
<Input
|
||||
id="cred-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My API Key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cred-description">Description</Label>
|
||||
<Input
|
||||
id="cred-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Credential Type</Label>
|
||||
<Select
|
||||
value={credentialType}
|
||||
onValueChange={(v) => {
|
||||
setCredentialType(v as WebhookCredentialType);
|
||||
setCredentialData({});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bearer_token">Bearer Token</SelectItem>
|
||||
<SelectItem value="api_key">API Key</SelectItem>
|
||||
<SelectItem value="basic_auth">Basic Auth</SelectItem>
|
||||
<SelectItem value="custom_header">Custom Header</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="grid gap-2">
|
||||
<Label htmlFor={`cred-${field.key}`}>{field.label}</Label>
|
||||
<Input
|
||||
id={`cred-${field.key}`}
|
||||
type={field.isSecret ? "password" : "text"}
|
||||
value={credentialData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
setCredentialData((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
140
ui/src/components/http/credential-selector.tsx
Normal file
140
ui/src/components/http/credential-selector.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2, PlusIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { listCredentialsApiV1CredentialsGet } from "@/client";
|
||||
import { CredentialResponse } from "@/client/types.gen";
|
||||
import { CreateCredentialDialog } from "@/components/http/create-credential-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface CredentialSelectorProps {
|
||||
value: string;
|
||||
onChange: (uuid: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function CredentialSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder = "No authentication",
|
||||
label = "Credential",
|
||||
description = "Select a credential for authentication, or leave empty for no auth.",
|
||||
showLabel = true,
|
||||
}: CredentialSelectorProps) {
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
const [credentials, setCredentials] = useState<CredentialResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await listCredentialsApiV1CredentialsGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.error) {
|
||||
console.error("Failed to fetch credentials:", response.error);
|
||||
setCredentials([]);
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
setCredentials(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch credentials:", error);
|
||||
setCredentials([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
const handleCredentialCreated = async (credential: CredentialResponse) => {
|
||||
await fetchCredentials();
|
||||
onChange(credential.uuid);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{showLabel && (
|
||||
<>
|
||||
<Label>{label}</Label>
|
||||
{description && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</Label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={value || "none"}
|
||||
onValueChange={(v) => onChange(v === "none" ? "" : v)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={placeholder} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{placeholder}</SelectItem>
|
||||
{credentials.map((cred) => (
|
||||
<SelectItem key={cred.uuid} value={cred.uuid}>
|
||||
{cred.name} ({cred.credential_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
title="Add new credential"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{credentials.length === 0 && !loading && (
|
||||
<div className="p-3 border rounded-md bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No credentials found. Click the + button to create one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateCredentialDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
onCreated={handleCredentialCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
ui/src/components/http/http-method-selector.tsx
Normal file
44
ui/src/components/http/http-method-selector.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface HttpMethodSelectorProps {
|
||||
value: HttpMethod;
|
||||
onChange: (method: HttpMethod) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const HTTP_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
export function HttpMethodSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: HttpMethodSelectorProps) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v as HttpMethod)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_METHODS.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
{method}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
5
ui/src/components/http/index.ts
Normal file
5
ui/src/components/http/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { CreateCredentialDialog } from "./create-credential-dialog";
|
||||
export { CredentialSelector } from "./credential-selector";
|
||||
export { type HttpMethod, HttpMethodSelector } from "./http-method-selector";
|
||||
export { KeyValueEditor, type KeyValueItem } from "./key-value-editor";
|
||||
export { ParameterEditor, type ParameterType,type ToolParameter } from "./parameter-editor";
|
||||
85
ui/src/components/http/key-value-editor.tsx
Normal file
85
ui/src/components/http/key-value-editor.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface KeyValueItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface KeyValueEditorProps {
|
||||
items: KeyValueItem[];
|
||||
onChange: (items: KeyValueItem[]) => void;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
addButtonText?: string;
|
||||
emptyMessage?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function KeyValueEditor({
|
||||
items,
|
||||
onChange,
|
||||
keyPlaceholder = "Key",
|
||||
valuePlaceholder = "Value",
|
||||
addButtonText = "Add",
|
||||
disabled = false,
|
||||
}: KeyValueEditorProps) {
|
||||
const addItem = () => {
|
||||
onChange([...items, { key: "", value: "" }]);
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: "key" | "value", value: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder={keyPlaceholder}
|
||||
value={item.key}
|
||||
onChange={(e) => updateItem(index, "key", e.target.value)}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
placeholder={valuePlaceholder}
|
||||
value={item.value}
|
||||
onChange={(e) => updateItem(index, "value", e.target.value)}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removeItem(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
className="w-fit"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-1" /> {addButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
ui/src/components/http/parameter-editor.tsx
Normal file
167
ui/src/components/http/parameter-editor.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export type ParameterType = "string" | "number" | "boolean";
|
||||
|
||||
export interface ToolParameter {
|
||||
name: string;
|
||||
type: ParameterType;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface ParameterEditorProps {
|
||||
parameters: ToolParameter[];
|
||||
onChange: (parameters: ToolParameter[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ParameterEditor({
|
||||
parameters,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: ParameterEditorProps) {
|
||||
const addParameter = () => {
|
||||
onChange([
|
||||
...parameters,
|
||||
{ name: "", type: "string", description: "", required: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateParameter = (
|
||||
index: number,
|
||||
field: keyof ToolParameter,
|
||||
value: string | boolean
|
||||
) => {
|
||||
const newParams = [...parameters];
|
||||
newParams[index] = { ...newParams[index], [field]: value };
|
||||
onChange(newParams);
|
||||
};
|
||||
|
||||
const removeParameter = (index: number) => {
|
||||
onChange(parameters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{parameters.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-md">
|
||||
No parameters defined. Add a parameter to specify what data this tool needs.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parameters.map((param, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border rounded-lg p-4 space-y-3 bg-muted/20"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Parameter {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeParameter(index)}
|
||||
disabled={disabled}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Name of the parameter, like "order_id" or "customer_name"
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="e.g., customer_name"
|
||||
value={param.name}
|
||||
onChange={(e) =>
|
||||
updateParameter(index, "name", e.target.value)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Type of the parameter, like "string" or "number" or "boolean"
|
||||
</Label>
|
||||
<Select
|
||||
value={param.type}
|
||||
onValueChange={(value: ParameterType) =>
|
||||
updateParameter(index, "type", value)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">String</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Description of the parameter, which makes it easy for LLM to understand, like "The ID of the Customer to fetch Order Details"
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Describe what this parameter is for..."
|
||||
value={param.description}
|
||||
onChange={(e) =>
|
||||
updateParameter(index, "description", e.target.value)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={`required-${index}`}
|
||||
checked={param.required}
|
||||
onCheckedChange={(checked) =>
|
||||
updateParameter(index, "required", checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="text-sm">
|
||||
Required
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addParameter}
|
||||
className="w-fit"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-1" /> Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Star,
|
||||
TrendingUp,
|
||||
Workflow,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -108,6 +109,11 @@ export function AppSidebar() {
|
|||
url: "/telephony-configurations",
|
||||
icon: Phone,
|
||||
},
|
||||
{
|
||||
title: "Tools",
|
||||
url: "/tools",
|
||||
icon: Wrench,
|
||||
},
|
||||
// {
|
||||
// title: "Integrations",
|
||||
// url: "/integrations",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue