From a3d01ca1b3edafe25c77d42c5ab88d83a5ca1823 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 7 Jul 2026 17:48:52 -0700 Subject: [PATCH] Tools schema generation and ux tweaks. --- app/src/components/Layout.tsx | 8 +- app/src/components/detail/ToolFields.tsx | 171 +++++++++++++++++++++-- app/src/components/detail/fields.tsx | 85 +++++++++-- 3 files changed, 239 insertions(+), 25 deletions(-) diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index 9ce04a5..4a1ff27 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -6,15 +6,15 @@ import { ChevronLeft, ChevronRight, Clock, + Gauge, Globe, KeyRound, LayoutDashboard, Menu, - Package, Search, Server, Share2, - Map as MapIcon, + SquareCode, Wrench, X, type LucideIcon, @@ -32,6 +32,7 @@ type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] } // "Deployments" parent. Programs (the catalog) stays top-level. const NAV: (NavLeaf | NavGroup)[] = [ { to: "/", label: "Overview", icon: LayoutDashboard, end: true }, + { to: "/map", label: "System", icon: Gauge }, { to: "/gateway", label: "Gateway", icon: Globe }, { label: "Deployments", @@ -42,8 +43,7 @@ const NAV: (NavLeaf | NavGroup)[] = [ { to: "/tools", label: "Tools", icon: Wrench }, ], }, - { to: "/programs", label: "Programs", icon: Package }, - { to: "/map", label: "System Map", icon: MapIcon }, + { to: "/programs", label: "Programs", icon: SquareCode }, { to: "/mesh", label: "Mesh", icon: Share2 }, { to: "/secrets", label: "Secrets", icon: KeyRound }, ] diff --git a/app/src/components/detail/ToolFields.tsx b/app/src/components/detail/ToolFields.tsx index 8404389..eb8ba38 100644 --- a/app/src/components/detail/ToolFields.tsx +++ b/app/src/components/detail/ToolFields.tsx @@ -1,6 +1,10 @@ import { useState } from "react" +import { Loader2 } from "lucide-react" import type { DeploymentDetail } from "@/types" -import { TextField, FormFooter, useEnvSecrets } from "./fields" +import { apiClient, ApiError } from "@/services/api/client" +import { Field, TextField, FormFooter } from "./fields" + +type GenKind = "help" | "deep" | "ai" interface Props { tool: DeploymentDetail @@ -8,30 +12,94 @@ interface Props { onDelete?: (name: string) => Promise } -type Obj = Record -const obj = (v: unknown): Obj => (v as Obj) ?? {} - /** Edit a tool's (path) deployment config. A path deployment has no launcher, - * port, or schedule — only a description and env, plus its manager. */ + * port, or schedule — only a description and its `tool_schema` (the neutral + * tool-call definition handed to agents), plus its manager. It has no run env: + * a tool is a CLI on PATH, invoked from a shell with castle out of the loop, so + * `defaults.env` is never applied (deploy.py only wires env for systemd units). */ export function ToolFields({ tool, onSave, onDelete }: Props) { const m = tool.manifest const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [description, setDescription] = useState((m.description as string) ?? "") - const { element: envEditor, merged } = useEnvSecrets( - obj(obj(m.defaults).env) as Record, + const [schemaText, setSchemaText] = useState( + m.tool_schema ? JSON.stringify(m.tool_schema, null, 2) : "", ) + const [schemaError, setSchemaError] = useState(null) + // Which generate action is in-flight (null = idle) — lets each button show its + // own spinner/label rather than all three reacting to one shared flag. + const [genKind, setGenKind] = useState(null) + const generating = genKind !== null + const [validating, setValidating] = useState(false) + const [validation, setValidation] = useState<{ ok: boolean; msg: string } | null>(null) + + const validate = async () => { + setValidation(null) + setSchemaError(null) + let parsed: unknown + try { + parsed = JSON.parse(schemaText) + } catch (e) { + setValidation({ ok: false, msg: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` }) + return + } + setValidating(true) + try { + const res = await apiClient.post<{ valid: boolean; errors: string[] }>( + "/config/tools/schema/validate", + parsed, + ) + setValidation( + res.valid + ? { ok: true, msg: "Schema is valid." } + : { ok: false, msg: res.errors.join(" · ") }, + ) + } catch (e) { + setValidation({ ok: false, msg: e instanceof ApiError ? e.message : String(e) }) + } finally { + setValidating(false) + } + } + + const generate = async (kind: GenKind) => { + setGenKind(kind) + setSchemaError(null) + setValidation(null) + try { + const params = new URLSearchParams() + if (kind === "deep") params.set("deep", "true") + if (kind === "ai") params.set("assist", "llm") + const qs = params.toString() + const res = await apiClient.post<{ schema: unknown }>( + `/config/tools/${tool.id}/schema${qs ? `?${qs}` : ""}`, + ) + setSchemaText(JSON.stringify(res.schema, null, 2)) + } catch (e) { + setSchemaError(e instanceof ApiError ? e.message : String(e)) + } finally { + setGenKind(null) + } + } const handleSave = async () => { + // Validate the (optional) tool_schema JSON before saving. + let parsedSchema: unknown = undefined + if (schemaText.trim()) { + try { + parsedSchema = JSON.parse(schemaText) + } catch (e) { + setSchemaError(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`) + return + } + } + setSchemaError(null) setSaving(true) setSaved(false) try { const config: Record = JSON.parse(JSON.stringify(m)) delete config.id config.description = description || undefined - const env = merged() - if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env } - else if (config.defaults) delete (config.defaults as Obj).env + config.tool_schema = parsedSchema ?? null // null clears (PATCH semantics) await onSave(tool.id, config) setSaved(true) setTimeout(() => setSaved(false), 2000) @@ -43,7 +111,88 @@ export function ToolFields({ tool, onSave, onDelete }: Props) { return (
- {envEditor} + +
+
+ + + + + {schemaText && ( + + )} +
+