|
@@ -0,0 +1,216 @@
|
|
|
|
|
+<script lang="ts">
|
|
|
|
|
+ import { Send } from '@lucide/svelte';
|
|
|
|
|
+
|
|
|
|
|
+ import type { MCPTool, ToolCallContentItem, ToolCallResponse } from '$lib/api/tools';
|
|
|
|
|
+ import * as toolsApi from '$lib/api/tools';
|
|
|
|
|
+ import { TestResult } from '$lib/components/common/test-result';
|
|
|
|
|
+ import { Button } from '$lib/components/controls/button';
|
|
|
|
|
+ import * as Resizable from '$lib/components/controls/resizable';
|
|
|
|
|
+ import { Spinner } from '$lib/components/controls/spinner';
|
|
|
|
|
+
|
|
|
|
|
+ let {
|
|
|
|
|
+ workspaceId,
|
|
|
|
|
+ toolId,
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ tool,
|
|
|
|
|
+ }: {
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ toolId: string;
|
|
|
|
|
+ sessionId: string;
|
|
|
|
|
+ tool: MCPTool;
|
|
|
|
|
+ } = $props();
|
|
|
|
|
+
|
|
|
|
|
+ let argsText = $state('{}');
|
|
|
|
|
+ let response = $state<ToolCallResponse | undefined>(undefined);
|
|
|
|
|
+ let loading = $state(false);
|
|
|
|
|
+ let error = $state<{ type: string; message: string } | undefined>(undefined);
|
|
|
|
|
+ const schemaText = $derived(JSON.stringify(tool.inputSchema, null, 2));
|
|
|
|
|
+
|
|
|
|
|
+ $effect(() => {
|
|
|
|
|
+ if (tool) {
|
|
|
|
|
+ argsText = argsTemplate(tool);
|
|
|
|
|
+ response = undefined;
|
|
|
|
|
+ error = undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ type JsonSchema = {
|
|
|
|
|
+ type?: string | string[];
|
|
|
|
|
+ required?: string[];
|
|
|
|
|
+ properties?: Record<string, JsonSchema>;
|
|
|
|
|
+ items?: JsonSchema | JsonSchema[];
|
|
|
|
|
+ oneOf?: JsonSchema[];
|
|
|
|
|
+ anyOf?: JsonSchema[];
|
|
|
|
|
+ allOf?: JsonSchema[];
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const argsTemplate = (tool: MCPTool): string => {
|
|
|
|
|
+ const pickSchema = (schema: JsonSchema): JsonSchema =>
|
|
|
|
|
+ schema.oneOf?.[0] ?? schema.anyOf?.[0] ?? schema.allOf?.[0] ?? schema;
|
|
|
|
|
+
|
|
|
|
|
+ const inferType = (schema: JsonSchema): string | undefined => {
|
|
|
|
|
+ if (Array.isArray(schema.type))
|
|
|
|
|
+ return schema.type.find((t) => t !== 'null') ?? schema.type[0];
|
|
|
|
|
+ if (schema.type) return schema.type;
|
|
|
|
|
+ if (schema.properties || schema.required) return 'object';
|
|
|
|
|
+ if (schema.items) return 'array';
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const buildSkeleton = (rawSchema: JsonSchema): unknown => {
|
|
|
|
|
+ const schema = pickSchema(rawSchema);
|
|
|
|
|
+ const schemaType = inferType(schema);
|
|
|
|
|
+
|
|
|
|
|
+ if (schemaType === 'object') {
|
|
|
|
|
+ const out: Record<string, unknown> = {};
|
|
|
|
|
+ for (const key of schema.required ?? []) {
|
|
|
|
|
+ out[key] = buildSkeleton(schema.properties?.[key] ?? {});
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (schemaType === 'array') {
|
|
|
|
|
+ const itemSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
|
|
|
|
+ if (!itemSchema) return [];
|
|
|
|
|
+ return [buildSkeleton(itemSchema)];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return '';
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const skeleton = buildSkeleton((tool.inputSchema as JsonSchema) ?? {});
|
|
|
|
|
+ return JSON.stringify(skeleton, null, 2);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const argsValid = $derived.by(() => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ JSON.parse(argsText);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const requiredMissing = $derived.by(() => {
|
|
|
|
|
+ if (!tool || !argsValid) return false;
|
|
|
|
|
+ const schema = tool.inputSchema as { required?: string[] };
|
|
|
|
|
+ if (!schema.required?.length) return false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(argsText) as Record<string, unknown>;
|
|
|
|
|
+ return schema.required.some((k) => !(k in parsed));
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const canCall = $derived(tool && argsValid && !requiredMissing && !loading);
|
|
|
|
|
+
|
|
|
|
|
+ const callTool = async () => {
|
|
|
|
|
+ if (!canCall) return;
|
|
|
|
|
+ response = undefined;
|
|
|
|
|
+ error = undefined;
|
|
|
|
|
+ loading = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ response = await toolsApi.callTool(
|
|
|
|
|
+ workspaceId,
|
|
|
|
|
+ toolId,
|
|
|
|
|
+ tool.name,
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ JSON.parse(argsText),
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ error =
|
|
|
|
|
+ err instanceof Error
|
|
|
|
|
+ ? { type: 'Request Error', message: err.message }
|
|
|
|
|
+ : { type: 'Unknown Error', message: 'An error occurred' };
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ loading = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<Resizable.PaneGroup class="max-h-96" direction="horizontal">
|
|
|
|
|
+ <!-- Schema -->
|
|
|
|
|
+ <Resizable.Pane class="bg-black/25" defaultSize={50}>
|
|
|
|
|
+ <pre class="h-full w-full overflow-auto p-4 font-mono text-xs text-white/75">{schemaText}</pre>
|
|
|
|
|
+ </Resizable.Pane>
|
|
|
|
|
+ <Resizable.Handle />
|
|
|
|
|
+ <!-- Arguments -->
|
|
|
|
|
+ <Resizable.Pane defaultSize={50}>
|
|
|
|
|
+ <div class="relative h-full w-full">
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ class="h-full w-full resize-none p-4 font-mono text-sm outline-none"
|
|
|
|
|
+ spellcheck={false}
|
|
|
|
|
+ bind:value={argsText}
|
|
|
|
|
+ >
|
|
|
|
|
+ </textarea>
|
|
|
|
|
+ <div class="absolute bottom-0 flex w-full items-center justify-between p-2">
|
|
|
|
|
+ {#if argsText && !argsValid}
|
|
|
|
|
+ <p class="mb-2 text-xs text-red-300">Invalid JSON</p>
|
|
|
|
|
+ {:else if requiredMissing}
|
|
|
|
|
+ <p class="mb-2 text-xs text-amber-300">
|
|
|
|
|
+ Missing required fields: {((tool.inputSchema as { required?: string[] }).required ?? [])
|
|
|
|
|
+ .filter((k) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return !(k in (JSON.parse(argsText) as Record<string, unknown>));
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .join(', ')}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <div></div>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ <Button disabled={!canCall} size="icon-sm" variant="ghost" onclick={callTool}>
|
|
|
|
|
+ {#if loading}
|
|
|
|
|
+ <Spinner />
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ <Send size={14} />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Resizable.Pane>
|
|
|
|
|
+</Resizable.PaneGroup>
|
|
|
|
|
+
|
|
|
|
|
+<!-- Result -->
|
|
|
|
|
+<TestResult class="border-t border-white/10 bg-black/25" {error} {loading} {response}>
|
|
|
|
|
+ {#snippet result(resp)}
|
|
|
|
|
+ {#if resp}
|
|
|
|
|
+ <div class="flex flex-col gap-2">
|
|
|
|
|
+ {#each resp.content as item, idx (idx)}
|
|
|
|
|
+ {@render contentItem(item)}
|
|
|
|
|
+ {/each}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ {/snippet}
|
|
|
|
|
+</TestResult>
|
|
|
|
|
+
|
|
|
|
|
+{#snippet contentItem(item: ToolCallContentItem)}
|
|
|
|
|
+ {#if item.type === 'text'}
|
|
|
|
|
+ <div class="rounded border border-white/6 bg-black/20 p-3">
|
|
|
|
|
+ <p class="mb-1 text-xs text-white/30">[text]</p>
|
|
|
|
|
+ <p class="text-sm whitespace-pre-wrap text-white/80">{item.text}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {:else if item.type === 'image'}
|
|
|
|
|
+ <div class="rounded border border-white/6 bg-black/20 p-3">
|
|
|
|
|
+ <p class="mb-2 text-xs text-white/30">[image]</p>
|
|
|
|
|
+ <img
|
|
|
|
|
+ class="max-w-full rounded"
|
|
|
|
|
+ alt="Tool result"
|
|
|
|
|
+ src="data:{item.mimeType};base64,{item.data}"
|
|
|
|
|
+ />
|
|
|
|
|
+ <p class="mt-1 text-xs text-white/30">{item.mimeType}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {:else if item.type === 'resource'}
|
|
|
|
|
+ <div class="rounded border border-white/6 bg-black/20 p-3">
|
|
|
|
|
+ <p class="mb-1 text-xs text-white/30">[resource]</p>
|
|
|
|
|
+ <p class="mb-1 font-mono text-xs text-primary">{item.resource.uri}</p>
|
|
|
|
|
+ {#if item.resource.text}
|
|
|
|
|
+ <p class="text-sm whitespace-pre-wrap text-white/70">{item.resource.text}</p>
|
|
|
|
|
+ {:else if item.resource.mimeType}
|
|
|
|
|
+ <p class="text-xs text-white/40">binary resource · {item.resource.mimeType}</p>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+{/snippet}
|