Spaces:
Paused
Paused
| <script lang="ts"> | |
| import { | |
| ToolOutputComponents, | |
| type CommunityToolEditable, | |
| type ToolInput, | |
| } from "$lib/types/Tool"; | |
| import { createEventDispatcher, onMount } from "svelte"; | |
| import { browser } from "$app/environment"; | |
| import ToolLogo from "$lib/components/ToolLogo.svelte"; | |
| import { colors, icons } from "$lib/utils/tools"; | |
| import { applyAction, enhance } from "$app/forms"; | |
| import { getGradioApi } from "$lib/utils/getGradioApi"; | |
| import { useSettingsStore } from "$lib/stores/settings"; | |
| import { goto } from "$app/navigation"; | |
| import { base } from "$app/paths"; | |
| import ToolInputComponent from "./ToolInputComponent.svelte"; | |
| import CarbonInformation from "~icons/carbon/information"; | |
| type ActionData = { | |
| error?: boolean; | |
| errors?: { | |
| field: string | number; | |
| message: string; | |
| }[]; | |
| } | null; | |
| export let tool: CommunityToolEditable | undefined = undefined; | |
| export let readonly = false; | |
| export let form: ActionData; | |
| function getError(field: string, returnForm: ActionData) { | |
| return returnForm?.errors?.find((error) => error.field === field)?.message ?? ""; | |
| } | |
| let APIloading = false; | |
| let formLoading = false; | |
| const dispatch = createEventDispatcher<{ close: void }>(); | |
| onMount(async () => { | |
| await updateConfig(); | |
| }); | |
| let spaceUrl = tool?.baseUrl ?? ""; | |
| let editableTool: CommunityToolEditable = tool ?? { | |
| displayName: "", | |
| description: "", | |
| // random color & icon for new tools | |
| color: colors[Math.floor(Math.random() * colors.length)], | |
| icon: icons[Math.floor(Math.random() * icons.length)], | |
| baseUrl: "", | |
| endpoint: "", | |
| name: "", | |
| inputs: [], | |
| outputComponent: null, | |
| outputComponentIdx: 0, | |
| showOutput: true, | |
| }; | |
| $: editableTool.baseUrl && (spaceUrl = editableTool.baseUrl); | |
| async function updateConfig() { | |
| if (!browser || !editableTool.baseUrl || !editableTool.endpoint) { | |
| return; | |
| } | |
| form = { error: false, errors: [] }; | |
| APIloading = true; | |
| const api = await getGradioApi(editableTool.baseUrl); | |
| const newInputs = api.named_endpoints[editableTool.endpoint].parameters.map((param, idx) => { | |
| if (tool?.inputs[idx]?.name === param.parameter_name) { | |
| // if the tool has the same name, we use the tool's type | |
| return { | |
| ...tool?.inputs[idx], | |
| } satisfies ToolInput; | |
| } | |
| const type = parseValidInputType(param.python_type.type); | |
| if (param.parameter_has_default && param.python_type.type !== "filepath") { | |
| // optional if it has a default | |
| return { | |
| name: param.parameter_name, | |
| description: param.description, | |
| paramType: "optional" as const, | |
| default: param.parameter_default, | |
| ...(type === "file" ? { mimeTypes: "*/*", type } : { type }), | |
| }; | |
| } else { | |
| // required if it doesn't have a default | |
| return { | |
| name: param.parameter_name, | |
| description: param.description, | |
| paramType: "required" as const, | |
| ...(type === "file" ? { mimeTypes: "*/*", type } : { type }), | |
| }; | |
| } | |
| }); | |
| editableTool.inputs = newInputs; | |
| // outout components | |
| const parsedOutputComponent = ToolOutputComponents.safeParse( | |
| api.named_endpoints[editableTool.endpoint].returns?.[0]?.component ?? null | |
| ); | |
| if (parsedOutputComponent.success) { | |
| editableTool.outputComponent = "0;" + parsedOutputComponent.data; | |
| } else { | |
| form = { | |
| error: true, | |
| errors: [ | |
| { | |
| field: "outputComponent", | |
| message: `Invalid output component. Type ${ | |
| api.named_endpoints[editableTool.endpoint].returns?.[0]?.component | |
| } is not yet supported. Feel free to report this issue so we can add support for it.`, | |
| }, | |
| ], | |
| }; | |
| editableTool.outputComponent = null; | |
| } | |
| APIloading = false; | |
| } | |
| async function onEndpointChange(e: Event) { | |
| const target = e.target as HTMLInputElement; | |
| editableTool.endpoint = target.value; | |
| editableTool.name = target.value.replace(/\//g, ""); | |
| await updateConfig(); | |
| } | |
| function parseValidInputType(type: string) { | |
| switch (type) { | |
| case "str": | |
| case "int": | |
| case "float": | |
| case "bool": | |
| return type; | |
| case "filepath": | |
| return "file" as const; | |
| default: | |
| return "str"; | |
| } | |
| } | |
| const settings = useSettingsStore(); | |
| $: formSubmittable = editableTool.name && editableTool.baseUrl && editableTool.outputComponent; | |
| </script> | |
| <form | |
| method="POST" | |
| class="relative flex h-full flex-col overflow-y-auto p-4 md:p-8" | |
| use:enhance={async ({ formData }) => { | |
| formLoading = true; | |
| formData.append("tool", JSON.stringify(editableTool)); | |
| return async ({ result }) => { | |
| if (result.type === "success" && result.data && typeof result.data.toolId === "string") { | |
| $settings.tools = [...($settings.tools ?? []), result.data.toolId]; | |
| await goto(`${base}/tools/${result.data.toolId}`).then(() => { | |
| formLoading = false; | |
| }); | |
| } else { | |
| await applyAction(result).then(() => { | |
| formLoading = false; | |
| }); | |
| } | |
| }; | |
| }} | |
| > | |
| {#if tool} | |
| <h2 class="text-xl font-semibold"> | |
| {readonly ? "View" : "Edit"} Tool: {tool.displayName} | |
| </h2> | |
| {#if !readonly} | |
| <p class="mb-6 text-sm text-gray-500"> | |
| Modifying an existing tool will propagate the changes to all users. | |
| </p> | |
| {/if} | |
| {:else} | |
| <h2 class="text-xl font-semibold">Create new tool</h2> | |
| <p class="mb-6 text-sm text-gray-500"> | |
| Create and share your own tools. All tools are <span | |
| class="rounded-full border px-2 py-0.5 leading-none">public</span | |
| > | |
| </p> | |
| {/if} | |
| <div class="grid h-full w-full flex-1 grid-cols-2 gap-6 text-sm max-sm:grid-cols-1"> | |
| <div class="col-span-1 flex flex-col gap-4"> | |
| <div class="flex flex-col gap-4"> | |
| <label> | |
| <div class="mb-1 font-semibold">Tool Display Name</div> | |
| <input | |
| type="text" | |
| name="displayName" | |
| disabled={readonly} | |
| class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" | |
| placeholder="Image generator" | |
| bind:value={editableTool.displayName} | |
| /> | |
| <p class="text-xs text-red-500">{getError("displayName", form)}</p> | |
| </label> | |
| <div class="flex flex-row gap-4"> | |
| <div> | |
| {#key editableTool.color + editableTool.icon} | |
| <ToolLogo color={editableTool.color} icon={editableTool.icon} /> | |
| {/key} | |
| </div> | |
| <label class="flex-grow"> | |
| <div class="mb-1 font-semibold">Icon</div> | |
| <select | |
| name="icon" | |
| disabled={readonly} | |
| class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" | |
| bind:value={editableTool.icon} | |
| > | |
| {#each icons as icon} | |
| <option value={icon}>{icon}</option> | |
| {/each} | |
| <p class="text-xs text-red-500">{getError("icon", form)}</p> | |
| </select> | |
| </label> | |
| <label class="flex-grow"> | |
| <div class="mb-1 font-semibold">Color scheme</div> | |
| <select | |
| name="color" | |
| disabled={readonly} | |
| class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" | |
| bind:value={editableTool.color} | |
| > | |
| {#each colors as color} | |
| <option value={color}>{color}</option> | |
| {/each} | |
| <p class="text-xs text-red-500">{getError("color", form)}</p> | |
| </select> | |
| </label> | |
| </div> | |
| <label> | |
| <div class=" font-semibold">Tool Description</div> | |
| <p class="mb-1 text-sm text-gray-500"> | |
| This description will be passed to the model when picking tools. Describe what your tool | |
| does and when it is appropriate to use. | |
| </p> | |
| <textarea | |
| name="description" | |
| disabled={readonly} | |
| class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" | |
| placeholder="This tool lets you generate images using SDXL." | |
| bind:value={editableTool.description} | |
| /> | |
| <p class="text-xs text-red-500">{getError("description", form)}</p> | |
| </label> | |
| <label> | |
| <div class="mb-1 font-semibold">Hugging Face Space URL</div> | |
| <p class="mb-1 text-sm text-gray-500"> | |
| Specify the Hugging Face Space where your tool is hosted. <a | |
| href="https://huggingface.co/spaces" | |
| target="_blank" | |
| class="underline">See trending spaces here</a | |
| >. | |
| </p> | |
| <input | |
| type="text" | |
| name="spaceUrl" | |
| disabled={readonly} | |
| class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" | |
| placeholder="ByteDance/Hyper-SDXL-1Step-T2I" | |
| bind:value={editableTool.baseUrl} | |
| /> | |
| <p class="text-xs text-red-500">{getError("spaceUrl", form)}</p> | |
| </label> | |
| <p class="text-justify text-gray-800"> | |
| Tools allows models that support them to use external application directly via function | |
| calling. Tools must use Hugging Face Gradio Spaces as we detect the input and output types | |
| automatically from the <a | |
| class="underline" | |
| href="https://www.gradio.app/guides/sharing-your-app#api-page">Gradio API</a | |
| >. For GPU intensive tool consider using a ZeroGPU Space. | |
| </p> | |
| </div> | |
| </div> | |
| <div class="col-span-1 flex flex-col gap-4"> | |
| <div class="flex flex-col gap-2"> | |
| <h3 class="mb-1 font-semibold">Functions</h3> | |
| {#if editableTool.baseUrl} | |
| <p class="text-sm text-gray-500">Choose functions that can be called in your tool.</p> | |
| {:else} | |
| <p class="text-sm text-gray-500">Start by specifying a Hugging Face Space URL.</p> | |
| {/if} | |
| {#if editableTool.baseUrl} | |
| {#await getGradioApi(spaceUrl)} | |
| <p class="text-sm text-gray-500">Loading...</p> | |
| {:then api} | |
| <div class="flex flex-row flex-wrap gap-4"> | |
| {#each Object.keys(api["named_endpoints"] ?? {}) as name} | |
| <label class="rounded-lg bg-gray-200 p-2"> | |
| <input | |
| type="radio" | |
| disabled={readonly} | |
| on:input={onEndpointChange} | |
| bind:group={editableTool.endpoint} | |
| value={name} | |
| name="endpoint" | |
| /> | |
| <span | |
| class="font-mono text-gray-800" | |
| class:font-semibold={editableTool.endpoint === name}>{name}</span | |
| > | |
| </label> | |
| {/each} | |
| </div> | |
| {#if editableTool.endpoint && api["named_endpoints"][editableTool.endpoint] && !APIloading} | |
| {@const endpoint = api["named_endpoints"][editableTool.endpoint]} | |
| <div class="flex flex-col gap-2"> | |
| <div class="flex flex-col gap-2 rounded-lg border border-gray-200 p-2"> | |
| <div class="flex items-center gap-1 border-b border-gray-200 p-1 pb-2"> | |
| <span class="flex-grow font-mono text-smd font-semibold" | |
| >{editableTool.endpoint}</span | |
| > | |
| <label class="ml-auto"> | |
| <span | |
| class="group relative flex w-max items-center justify-center text-sm font-semibold text-gray-700" | |
| > | |
| AI Function Name | |
| <CarbonInformation class="m-1 align-middle text-xs text-purple-500" /> | |
| <div | |
| class="pointer-events-none absolute -top-16 right-0 w-max rounded-md bg-gray-100 p-2 opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-800" | |
| > | |
| <p class="max-w-sm text-sm font-normal text-gray-800 dark:text-gray-200"> | |
| This is the function name that will be used when prompting the model. | |
| Make sure it describes your tool well, is short and unique. | |
| </p> | |
| </div> | |
| </span> | |
| <input | |
| class="h-fit rounded-lg border-2 border-gray-200 bg-gray-100 p-1" | |
| type="text" | |
| name="name" | |
| disabled={readonly} | |
| bind:value={editableTool.name} | |
| /> | |
| </label> | |
| </div> | |
| <div> | |
| <h3 class="text-lg font-semibold">Arguments</h3> | |
| <p class="mb-2 text-sm text-gray-500"> | |
| Choose parameters that can be passed to your tool. | |
| </p> | |
| </div> | |
| <p class="text-xs text-red-500"> | |
| {getError(`inputs`, form)} | |
| </p> | |
| {#each editableTool.inputs as input, inputIdx} | |
| {@const parameter = endpoint.parameters.find( | |
| (parameter) => parameter.parameter_name === input.name | |
| )} | |
| <div class="flex items-center gap-1"> | |
| <div class="inline w-full"> | |
| <span class="font-mono text-sm">{input.name}</span> | |
| <span | |
| class="inline-block max-w-lg truncate rounded-lg bg-orange-50 p-1 text-sm text-orange-800" | |
| >{parameter?.python_type.type}</span | |
| > | |
| {#if parameter?.description} | |
| <p class="text-sm text-gray-500"> | |
| {parameter.description} | |
| </p> | |
| {/if} | |
| <div class="flex w-fit justify-start gap-2"> | |
| <label class="ml-auto"> | |
| <input | |
| type="radio" | |
| name="{input.name}-parameter-type" | |
| value="required" | |
| disabled={readonly} | |
| bind:group={editableTool.inputs[inputIdx].paramType} | |
| /> | |
| <span class="text-sm text-gray-500">Required</span> | |
| </label> | |
| <label class="ml-auto"> | |
| <input | |
| type="radio" | |
| name="{input.name}-parameter-type" | |
| value="optional" | |
| disabled={readonly || parameter?.python_type.type === "filepath"} | |
| bind:group={editableTool.inputs[inputIdx].paramType} | |
| /> | |
| <span class="text-sm text-gray-500">Optional</span> | |
| </label> | |
| <label class="ml-auto"> | |
| <input | |
| type="radio" | |
| name="{input.name}-parameter-type" | |
| value="fixed" | |
| disabled={readonly || parameter?.python_type.type === "filepath"} | |
| bind:group={editableTool.inputs[inputIdx].paramType} | |
| /> | |
| <span class="text-sm text-gray-500">Fixed</span> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- for required we need a description, for optional we need a default value and for fixed we need a value --> | |
| {#if input.paramType === "required" || input.paramType === "optional"} | |
| <label class="flex flex-row gap-2"> | |
| <div class="mb-1 font-semibold"> | |
| Description | |
| <p class="text-xs font-normal text-gray-500"> | |
| Will be passed in the model prompt, make it as clear and concise as | |
| possible | |
| </p> | |
| </div> | |
| <textarea | |
| name="{input.name}-description" | |
| class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" | |
| placeholder="This is the description of the input." | |
| bind:value={input.description} | |
| disabled={readonly} | |
| /> | |
| </label> | |
| {/if} | |
| {#if input.paramType === "optional" || input.paramType === "fixed"} | |
| {@const isOptional = input.paramType === "optional"} | |
| <div class="flex flex-row gap-2"> | |
| <div class="mb-1 flex-grow font-semibold"> | |
| {isOptional ? "Default value" : "Value"} | |
| <p class="text-xs font-normal text-gray-500"> | |
| {#if isOptional} | |
| The tool will use this value by default but the model can specify a | |
| different one. | |
| {:else} | |
| The tool will use this value and it cannot be changed. | |
| {/if} | |
| </p> | |
| </div> | |
| {#if input.paramType === "optional"} | |
| <ToolInputComponent | |
| type={parameter?.python_type.type ?? "str"} | |
| disabled={readonly} | |
| bind:value={input.default} | |
| /> | |
| {:else} | |
| <ToolInputComponent | |
| type={parameter?.python_type.type ?? "str"} | |
| disabled={readonly} | |
| bind:value={input.value} | |
| /> | |
| {/if} | |
| </div> | |
| {/if} | |
| {#if input.type === "file"} | |
| <label class="flex flex-row gap-2"> | |
| <div class="mb-1 font-semibold"> | |
| MIME types | |
| <p class="text-xs font-normal text-gray-500"> | |
| This input is a file. Specify the MIME types that are allowed to be | |
| passed to the tool. | |
| </p> | |
| </div> | |
| <select | |
| name="{input.name}-mimeTypes" | |
| class="h-fit w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" | |
| bind:value={input.mimeTypes} | |
| disabled={readonly} | |
| > | |
| <option value="image/*">image/*</option> | |
| <option value="audio/*">audio/*</option> | |
| <option value="video/*">video/*</option> | |
| <option value="application/pdf">application/pdf</option> | |
| <option value="text/csv">text/csv</option> | |
| <option value="*/*">*/*</option> | |
| </select></label | |
| > | |
| {/if} | |
| <!-- divider --> | |
| <div | |
| class="flex w-full flex-row flex-nowrap gap-2 border-b border-gray-200 pt-2" | |
| /> | |
| {/each} | |
| <div class="flex flex-col gap-4"> | |
| <div> | |
| <h3 class="text-lg font-semibold">Output options</h3> | |
| <p class="mb-2 text-sm text-gray-500"> | |
| Choose what value your tool will return and how | |
| </p> | |
| </div> | |
| <label class="flex flex-col gap-2" for="showOutput"> | |
| <div class="mb-1 font-semibold"> | |
| Output component | |
| <p class="text-xs font-normal text-gray-500"> | |
| Pick the gradio output component whose output will be used in the tool. | |
| </p> | |
| </div> | |
| {#if editableTool.outputComponent} | |
| {#if api.named_endpoints[editableTool.endpoint].returns.length > 1} | |
| <div class="flex flex-row gap-4"> | |
| {#each api.named_endpoints[editableTool.endpoint].returns as { component }, idx} | |
| <label class="text-gray-800"> | |
| <input | |
| type="radio" | |
| disabled={readonly || | |
| !ToolOutputComponents.safeParse(component).success} | |
| bind:group={editableTool.outputComponent} | |
| value={idx + ";" + component.toLowerCase()} | |
| name="outputComponent" | |
| /> | |
| <span | |
| class="font-mono" | |
| class:text-gray-400={!ToolOutputComponents.safeParse(component) | |
| .success} | |
| class:font-semibold={editableTool?.outputComponent?.split( | |
| ";" | |
| )[1] === component}>{component.toLowerCase()}-{idx}</span | |
| > | |
| </label> | |
| {/each} | |
| </div> | |
| {:else} | |
| <div> | |
| <input disabled checked type="radio" /> | |
| <span class="font-mono text-gray-800" | |
| >{editableTool.outputComponent.split(";")[1]}</span | |
| > | |
| </div> | |
| {/if} | |
| {/if} | |
| <p class="text-xs text-red-500"> | |
| {getError("outputComponent", form)} | |
| </p> | |
| </label> | |
| <label class="flex flex-row gap-2" for="showOutput"> | |
| <div class="mb-1 font-semibold"> | |
| Show output to user directly | |
| <p class="text-xs font-normal text-gray-500"> | |
| Some tools return long context that should not be shown to the user | |
| directly. | |
| </p> | |
| </div> | |
| <input | |
| type="checkbox" | |
| name="showOutput" | |
| bind:checked={editableTool.showOutput} | |
| class="peer rounded-lg border-2 border-gray-200 bg-gray-100 p-1" | |
| /> | |
| <p class="text-xs text-red-500"> | |
| {getError("showOutput", form)} | |
| </p> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| {:else if APIloading} | |
| <p class="text-sm text-gray-500">Loading API...</p> | |
| {:else if !api["named_endpoints"]} | |
| <p class="font-medium text-red-800"> | |
| No endpoints found in this space. Try another one. | |
| </p> | |
| {/if} | |
| {:catch error} | |
| <p class="text-sm text-gray-500">{error}</p> | |
| {/await} | |
| {/if} | |
| </div> | |
| <div class="relative bottom-0 mb-4 mt-auto flex w-full flex-row justify-end gap-2"> | |
| <button | |
| type="button" | |
| class="mt-4 w-fit rounded-full bg-gray-200 px-4 py-2 font-semibold text-gray-700" | |
| on:click={() => dispatch("close")} | |
| > | |
| Cancel | |
| </button> | |
| {#if !readonly} | |
| <button | |
| type="submit" | |
| disabled={formLoading || !formSubmittable} | |
| class="mt-4 w-fit rounded-full bg-black px-4 py-2 font-semibold" | |
| class:text-white={!formLoading && formSubmittable} | |
| class:text-gray-300={formLoading || !formSubmittable} | |
| class:bg-gray-400={formLoading || !formSubmittable} | |
| > | |
| {formLoading ? "Saving..." : "Save"} | |
| </button> | |
| {/if} | |
| </div> | |
| </div> | |
| </div> | |
| </form> | |