File size: 25,932 Bytes
fb57ab3
 
86da9e3
fb57ab3
 
 
86da9e3
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86da9e3
 
 
 
 
 
 
 
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
86da9e3
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
86da9e3
 
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
 
a50a219
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86da9e3
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86da9e3
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86da9e3
 
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86da9e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb57ab3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86da9e3
fb57ab3
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
import { absolutePosition, fnToDef } from "./groups";
import { isUsable, searchSpaces, stageLabel } from "./hub";
import { absRect } from "./layout";
import type { ToolSpec } from "./llm";
import { allDefs, canConnect, getDef, hasDef } from "./registry";
import type { NodeDef, OutputValue } from "./types";
import { requestFit, visibleArea } from "./viewport";
import { useCanvasStore } from "../store/useCanvasStore";

/** read: runs immediately. edit: needs approval in "ask". run: needs approval unless "yolo". */
export type ToolKind = "read" | "edit" | "run";

interface Tool {
  kind: ToolKind;
  spec: ToolSpec;
  /** One line shown in the approval card. */
  summarise: (args: Record<string, unknown>) => string;
  run: (args: Record<string, unknown>, ctx: { token: string | null; signal: AbortSignal }) => Promise<unknown>;
}

const str = (v: unknown, fallback = "") => (typeof v === "string" ? v : fallback);

function describeDef(d: NodeDef) {
  return {
    type: d.type,
    title: d.title,
    description: d.description,
    inputs: d.inputs.map((p) => ({ id: p.id, type: p.type, label: p.label, optional: !!p.optional, widget: p.widget ? { kind: p.widget.kind, default: p.widget.default, options: (p.widget as { options?: unknown }).options, min: (p.widget as { min?: number }).min, max: (p.widget as { max?: number }).max } : undefined })),
    outputs: d.outputs.map((p) => ({ id: p.id, type: p.type, label: p.label })),
    widgets: d.widgets.map((w) => ({ id: w.id, kind: w.kind, label: w.label, default: w.default })),
    space: d.space ? { id: d.space.id, endpoint: d.space.apiName } : undefined,
  };
}

type AnyNode = ReturnType<typeof useCanvasStore.getState>["nodes"][number];

/** Where a node sits on the canvas, in absolute canvas coordinates (top-left), plus its rendered size. */
function placement(all: AnyNode[], n: AnyNode) {
  const r = absRect(all, n);
  return { position: { x: r.x, y: r.y }, width: r.width, height: r.height, ...(n.parentId ? { group: n.parentId } : {}) };
}

function snapshotWorkflow() {
  const s = useCanvasStore.getState();
  const nodes = s.nodes
    .filter((n) => n.type !== "group")
    .map((n) => {
      const def = getDef(n.data.defType);
      const linked: Record<string, string> = {};
      for (const e of s.edges) if (e.target === n.id && e.targetHandle) linked[e.targetHandle] = `${e.source}.${e.sourceHandle}`;
      return {
        id: n.id,
        type: n.data.defType,
        title: n.data.title ?? def.title,
        ...placement(s.nodes, n),
        mode: n.data.mode,
        status: n.data.status,
        error: n.data.error,
        values: n.data.values,
        status_text: n.data.statusText,
        linked_inputs: linked,
        inputs: def.inputs.map((p) => `${p.id}:${p.type}${p.optional ? "?" : ""}`),
        outputs: def.outputs.map((p) => `${p.id}:${p.type}`),
        last_outputs: n.data.outputs ? summariseOutputs(n.data.outputs, 160) : undefined,
      };
    });
  const edges = s.edges.map((e) => ({ id: e.id, from: `${e.source}.${e.sourceHandle}`, to: `${e.target}.${e.targetHandle}` }));
  const groups = s.nodes.filter((n) => n.type === "group").map((n) => ({ id: n.id, title: n.data.title, ...placement(s.nodes, n) }));
  return { name: s.workflowName, run: runSummary(), viewport: visibleArea(), nodes, ...(groups.length ? { groups } : {}), edges, spaces: Object.values(s.spaces).map((sp) => ({ id: sp.summary.id, status: sp.status, error: sp.error, endpoints: sp.defs.map((d) => d.type) })) };
}

const GAP = 40;
const NEW_W = 260;
const NEW_H = 160;
type Rect = { x: number; y: number; w: number; h: number };

const overlaps = (a: Rect, b: Rect, pad: number) => a.x < b.x + b.w + pad && a.x + a.w + pad > b.x && a.y < b.y + b.h + pad && a.y + a.h + pad > b.y;

/**
 * Picks a spot for a new node: to the right of `near` (or of the whole graph),
 * then slides down past anything already occupying that column so nodes never land on top of each other.
 */
export function nextPosition(near?: string) {
  const { nodes } = useCanvasStore.getState();
  const rects: Rect[] = nodes.map((n) => {
    const p = absolutePosition(nodes, n);
    return { x: p.x, y: p.y, w: n.measured?.width ?? n.width ?? NEW_W, h: n.measured?.height ?? n.height ?? (n.data.collapsed ? 32 : 140) };
  });
  const src = near ? nodes.find((n) => n.id === near) : undefined;
  let x: number;
  let y: number;
  if (src) {
    const r = rects[nodes.indexOf(src)];
    x = r.x + r.w + GAP * 2;
    y = r.y;
  } else if (!nodes.length) {
    return { x: 80, y: 120 };
  } else {
    x = Math.max(...rects.map((r) => r.x + r.w)) + GAP * 2;
    y = Math.min(...rects.map((r) => r.y));
  }
  const snap = (v: number) => Math.round(v / 20) * 20;
  x = snap(x);
  y = snap(y);
  // Slide down until the slot is free; the column to the right of everything is free by construction,
  // but a `near` placement can collide with the source's other neighbours.
  for (let i = 0; i < 200; i++) {
    const hit = rects.find((r) => overlaps({ x, y, w: NEW_W, h: NEW_H }, r, GAP / 2));
    if (!hit) break;
    y = snap(hit.y + hit.h + GAP);
  }
  return { x, y };
}

/** Compact, model-friendly view of node outputs: file URLs kept, long text/JSON truncated. */
function summariseOutputs(out: Record<string, OutputValue>, max: number): Record<string, unknown> {
  const one = (v: OutputValue): unknown => {
    if (v == null || typeof v === "number" || typeof v === "boolean") return v;
    if (typeof v === "string") return v.length > max ? `${v.slice(0, max)}… (${v.length} chars)` : v;
    if (Array.isArray(v)) return v.slice(0, 8).map((x) => one(x as OutputValue));
    if (typeof v === "object" && "url" in v && typeof v.url === "string") return { url: v.url, name: v.name, mime: v.mime };
    const j = JSON.stringify(v);
    return j.length > max ? `${j.slice(0, max)}… (${j.length} chars)` : v;
  };
  return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, one(v)]));
}

function runSummary() {
  const s = useCanvasStore.getState();
  return {
    running: s.run.running,
    current_node: s.run.currentNodeId,
    completed: s.run.completed,
    total: s.run.total,
    queued: s.queue.length,
    last_result: s.history[0] ? { status: s.history[0].status, duration_ms: s.history[0].durationMs, nodes: s.history[0].nodes, error: s.history[0].error, finished_at: new Date(s.history[0].finishedAt).toISOString() } : undefined,
  };
}

const fmtLog = (e: { t: number; level: string; node?: string; msg: string }) => `${new Date(e.t).toISOString().slice(11, 23)} ${e.level.toUpperCase().padEnd(5)} ${e.msg}`;

export const TOOLS: Record<string, Tool> = {
  get_workflow: {
    kind: "read",
    spec: { type: "function", function: { name: "get_workflow", description: "Returns the current workflow: nodes (with ids, types, values, port lists, canvas position, width/height and group), groups, edges, the visible viewport area, and which Spaces are loaded.", parameters: { type: "object", properties: {} } } },
    summarise: () => "Read the workflow",
    run: async () => snapshotWorkflow(),
  },
  get_node: {
    kind: "read",
    spec: { type: "function", function: { name: "get_node", description: "Full detail for one node: definition (ports with labels, widgets with options/ranges), current values, incoming links, status, status text, error, progress and complete last outputs (text up to 4000 chars, file URLs).", parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } } },
    summarise: (a) => `Inspect ${nodeLabel(str(a.id))}`,
    run: async (a) => {
      const s = useCanvasStore.getState();
      const n = s.nodes.find((x) => x.id === str(a.id));
      if (!n) return { error: "No such node" };
      const def = getDef(n.data.defType);
      const linked: Record<string, string> = {};
      for (const e of s.edges) if (e.target === n.id && e.targetHandle) linked[e.targetHandle] = `${e.source}.${e.sourceHandle}`;
      const feeds = s.edges.filter((e) => e.source === n.id).map((e) => `${e.sourceHandle} β†’ ${e.target}.${e.targetHandle}`);
      return {
        id: n.id,
        type: n.data.defType,
        title: n.data.title ?? def.title,
        description: def.description,
        ...placement(s.nodes, n),
        mode: n.data.mode,
        status: n.data.status,
        status_text: n.data.statusText,
        progress: n.data.progress,
        error: n.data.error,
        values: n.data.values,
        linked_inputs: linked,
        feeds,
        inputs: def.inputs.map((p) => ({ id: p.id, label: p.label, type: p.type, optional: p.optional, widget: p.widget })),
        outputs: def.outputs.map((p) => ({ id: p.id, label: p.label, type: p.type })),
        widgets: def.widgets,
        last_outputs: n.data.outputs ? summariseOutputs(n.data.outputs, 4000) : undefined,
        log: s.log.filter((e) => e.node === n.id).slice(-30).map(fmtLog),
      };
    },
  },
  get_run_status: {
    kind: "read",
    spec: { type: "function", function: { name: "get_run_status", description: "Run state, queue, the last runs, per-node statuses/errors, and the run log (newest last). Use it after run_workflow, or whenever the user reports something failed.", parameters: { type: "object", properties: { log_lines: { type: "number", description: "How many log lines to include (default 60, max 400)" }, node: { type: "string", description: "Only log lines for this node id" } } } } },
    summarise: () => "Read run status and log",
    run: async (a) => {
      const s = useCanvasStore.getState();
      const limit = Math.min(400, Math.max(1, Number(a.log_lines) || 60));
      const node = str(a.node);
      const log = (node ? s.log.filter((e) => e.node === node) : s.log).slice(-limit).map(fmtLog);
      return {
        ...runSummary(),
        queue: s.queue.map((q) => ({ id: q.id, queued_at: new Date(q.queuedAt).toISOString() })),
        recent_runs: s.history.slice(0, 5).map((h) => ({ status: h.status, duration_ms: h.durationMs, nodes: h.nodes, error: h.error, finished_at: new Date(h.finishedAt).toISOString() })),
        nodes: s.nodes.filter((n) => n.type !== "group").map((n) => ({ id: n.id, title: n.data.title ?? getDef(n.data.defType).title, status: n.data.status, status_text: n.data.statusText, error: n.data.error })),
        log,
      };
    },
  },
  wait_for_run: {
    kind: "read",
    spec: { type: "function", function: { name: "wait_for_run", description: "Blocks until the current run (and queue) finishes or the timeout passes, then returns the same data as get_run_status. Call it right after run_workflow to see what happened.", parameters: { type: "object", properties: { timeout_s: { type: "number", description: "Max seconds to wait (default 120, max 600)" } } } } },
    summarise: (a) => `Wait for the run${a.timeout_s ? ` (≀${a.timeout_s}s)` : ""}`,
    run: async (a, ctx) => {
      const { signal } = ctx;
      const timeout = Math.min(600, Math.max(1, Number(a.timeout_s) || 120)) * 1000;
      const start = Date.now();
      // Give enqueue a tick to flip `running` on.
      await new Promise((r) => setTimeout(r, 300));
      const busy = () => {
        const s = useCanvasStore.getState();
        return s.run.running || s.queue.length > 0;
      };
      while (busy() && Date.now() - start < timeout && !signal?.aborted) await new Promise((r) => setTimeout(r, 500));
      const timed_out = busy();
      const status = await TOOLS.get_run_status.run({ log_lines: 80 }, ctx);
      return { timed_out, waited_ms: Date.now() - start, ...(status as object) };
    },
  },
  get_space_info: {
    kind: "read",
    spec: { type: "function", function: { name: "get_space_info", description: "Detail for a loaded Space: hub metadata, runtime stage/hardware, load status or error, and every endpoint with its full ports and widgets.", parameters: { type: "object", properties: { id: { type: "string", description: "owner/name" } }, required: ["id"] } } },
    summarise: (a) => `Inspect Space ${str(a.id)}`,
    run: async (a) => {
      const sp = useCanvasStore.getState().spaces[str(a.id)];
      if (!sp) return { error: "Space not loaded. Call add_space first." };
      const m = sp.summary;
      return {
        id: m.id,
        title: m.title,
        description: m.shortDescription,
        likes: m.likes,
        sdk: m.sdk,
        stage: m.stage,
        hardware: m.hardware,
        zero_gpu: m.zeroGpu,
        host: sp.host,
        status: sp.status,
        error: sp.error,
        loaded_at: sp.loadedAt ? new Date(sp.loadedAt).toISOString() : undefined,
        endpoints: sp.defs.map((d) => ({ type: d.type, title: d.title, description: d.description, inputs: d.inputs.map((p) => ({ id: p.id, label: p.label, type: p.type, optional: p.optional, widget: p.widget })), outputs: d.outputs.map((p) => ({ id: p.id, label: p.label, type: p.type })) })),
      };
    },
  },
  list_node_types: {
    kind: "read",
    spec: { type: "function", function: { name: "list_node_types", description: "Lists node types available right now (input nodes, loaded Space endpoints, saved functions) with their ports and widgets. Call add_space first to make a Space's endpoints available.", parameters: { type: "object", properties: { type: { type: "string", description: "Optional: a single node type to describe in full" } } } } },
    summarise: () => "List node types",
    run: async (a) => {
      const t = str(a.type);
      const defs = [...allDefs(), ...useCanvasStore.getState().functions.map(fnToDef)];
      if (t) {
        const d = defs.find((x) => x.type === t);
        return d ? describeDef(d) : { error: `Unknown node type ${t}` };
      }
      return defs.map((d) => ({ type: d.type, title: d.title, category: d.category, inputs: d.inputs.map((p) => `${p.id}:${p.type}`), outputs: d.outputs.map((p) => `${p.id}:${p.type}`) }));
    },
  },
  search_spaces: {
    kind: "read",
    spec: { type: "function", function: { name: "search_spaces", description: "Searches public Gradio Spaces on the Hugging Face Hub, sorted by likes. Prefer running Spaces (usable=true).", parameters: { type: "object", properties: { query: { type: "string" }, limit: { type: "integer", default: 12 } }, required: ["query"] } } },
    summarise: (a) => `Search Spaces for β€œ${str(a.query)}”`,
    run: async (a, ctx) => {
      const list = await searchSpaces(str(a.query), { token: ctx.token, signal: ctx.signal, limit: Math.min(Number(a.limit) || 12, 30) });
      return list.map((s) => ({ id: s.id, title: s.title, likes: s.likes, stage: stageLabel(s.stage), usable: isUsable(s.stage), hardware: s.hardware, zeroGpu: s.zeroGpu, description: s.shortDescription }));
    },
  },
  add_space: {
    kind: "edit",
    spec: { type: "function", function: { name: "add_space", description: "Loads a Space's API schema and makes its endpoints available as node types (returned in the result). Does not add a node.", parameters: { type: "object", properties: { id: { type: "string", description: "owner/name" } }, required: ["id"] } } },
    summarise: (a) => `Add Space ${str(a.id)}`,
    run: async (a) => {
      const entry = await useCanvasStore.getState().addSpace(str(a.id));
      if (!entry) {
        const e = useCanvasStore.getState().spaces[str(a.id)];
        return { error: e?.error ?? "Could not load this Space" };
      }
      return { id: entry.summary.id, stage: stageLabel(entry.summary.stage), endpoints: entry.defs.map(describeDef) };
    },
  },
  add_node: {
    kind: "edit",
    spec: { type: "function", function: { name: "add_node", description: "Adds a node of the given type. Returns its id. Optionally sets widget values and a title.", parameters: { type: "object", properties: { type: { type: "string" }, title: { type: "string" }, values: { type: "object", additionalProperties: true }, near: { type: "string", description: "Node id to place this node to the right of" } }, required: ["type"] } } },
    summarise: (a) => `Add node ${hasDef(str(a.type)) ? getDef(str(a.type)).title : str(a.type)}${a.title ? ` β€œ${str(a.title)}”` : ""}`,
    run: async (a) => {
      const type = str(a.type);
      const def = hasDef(type) ? getDef(type) : useCanvasStore.getState().functions.map(fnToDef).find((d) => d.type === type);
      if (!def) return { error: `Unknown node type ${type}. Call list_node_types or add_space first.` };
      const s = useCanvasStore.getState();
      const id = s.addNode(type, nextPosition(str(a.near) || undefined), false);
      if (a.title) s.patchNode(id, { title: str(a.title) });
      const vals = (a.values ?? {}) as Record<string, string | number | boolean>;
      const rejected: string[] = [];
      for (const [k, v] of Object.entries(vals)) {
        const known = def.widgets.some((w) => w.id === k) || def.inputs.some((p) => p.id === k && p.widget);
        if (known) s.updateWidget(id, k, v);
        else rejected.push(k);
      }
      const added = useCanvasStore.getState().nodes.find((x) => x.id === id);
      return { id, ...(added ? { position: placement(useCanvasStore.getState().nodes, added).position } : {}), ...(rejected.length ? { ignored_values: rejected } : {}) };
    },
  },
  set_values: {
    kind: "edit",
    spec: { type: "function", function: { name: "set_values", description: "Sets widget / input values on a node.", parameters: { type: "object", properties: { node: { type: "string" }, values: { type: "object", additionalProperties: true } }, required: ["node", "values"] } } },
    summarise: (a) => `Set ${Object.keys((a.values as object) ?? {}).join(", ")} on ${nodeLabel(str(a.node))}`,
    run: async (a) => {
      const s = useCanvasStore.getState();
      const n = s.nodes.find((x) => x.id === str(a.node));
      if (!n) return { error: "No such node" };
      const def = getDef(n.data.defType);
      const rejected: string[] = [];
      for (const [k, v] of Object.entries((a.values ?? {}) as Record<string, string | number | boolean>)) {
        const known = def.widgets.some((w) => w.id === k) || def.inputs.some((p) => p.id === k && p.widget);
        if (known) s.updateWidget(n.id, k, v);
        else rejected.push(k);
      }
      return { ok: true, ...(rejected.length ? { ignored_values: rejected } : {}) };
    },
  },
  connect: {
    kind: "edit",
    spec: { type: "function", function: { name: "connect", description: "Links an output port to an input port. Types must be compatible. Replaces any existing link on that input.", parameters: { type: "object", properties: { from_node: { type: "string" }, from_port: { type: "string" }, to_node: { type: "string" }, to_port: { type: "string" } }, required: ["from_node", "from_port", "to_node", "to_port"] } } },
    summarise: (a) => `Link ${nodeLabel(str(a.from_node))}.${str(a.from_port)} β†’ ${nodeLabel(str(a.to_node))}.${str(a.to_port)}`,
    run: async (a) => {
      const s = useCanvasStore.getState();
      const src = s.nodes.find((x) => x.id === str(a.from_node));
      const tgt = s.nodes.find((x) => x.id === str(a.to_node));
      if (!src || !tgt) return { error: "No such node" };
      const sp = getDef(src.data.defType).outputs.find((p) => p.id === str(a.from_port));
      const tp = getDef(tgt.data.defType).inputs.find((p) => p.id === str(a.to_port));
      if (!sp) return { error: `${src.id} has no output ${str(a.from_port)}` };
      if (!tp) return { error: `${tgt.id} has no input ${str(a.to_port)}` };
      if (!canConnect(sp.type, tp.type)) return { error: `Type mismatch: ${sp.type} β†’ ${tp.type}` };
      s.setInputSource(tgt.id, tp.id, { nodeId: src.id, portId: sp.id });
      return { ok: true };
    },
  },
  disconnect: {
    kind: "edit",
    spec: { type: "function", function: { name: "disconnect", description: "Removes the link feeding an input port.", parameters: { type: "object", properties: { node: { type: "string" }, port: { type: "string" } }, required: ["node", "port"] } } },
    summarise: (a) => `Unlink ${nodeLabel(str(a.node))}.${str(a.port)}`,
    run: async (a) => {
      useCanvasStore.getState().setInputSource(str(a.node), str(a.port), null);
      return { ok: true };
    },
  },
  remove_node: {
    kind: "edit",
    spec: { type: "function", function: { name: "remove_node", description: "Deletes a node and its links.", parameters: { type: "object", properties: { node: { type: "string" } }, required: ["node"] } } },
    summarise: (a) => `Delete ${nodeLabel(str(a.node))}`,
    run: async (a) => {
      const s = useCanvasStore.getState();
      if (!s.nodes.some((n) => n.id === str(a.node))) return { error: "No such node" };
      s.removeNode(str(a.node));
      return { ok: true };
    },
  },
  move_nodes: {
    kind: "edit",
    spec: { type: "function", function: { name: "move_nodes", description: "Moves nodes to absolute canvas positions (top-left corner, same coordinates as get_workflow's position). One undoable step. Leave about 80px horizontally and 40px vertically between nodes, using their width/height so nothing overlaps.", parameters: { type: "object", properties: { moves: { type: "array", items: { type: "object", properties: { node: { type: "string" }, x: { type: "number" }, y: { type: "number" } }, required: ["node", "x", "y"] } } }, required: ["moves"] } } },
    summarise: (a) => {
      const m = Array.isArray(a.moves) ? (a.moves as { node?: unknown }[]) : [];
      return m.length === 1 ? `Move ${nodeLabel(str(m[0].node))}` : `Move ${m.length} nodes`;
    },
    run: async (a) => {
      const raw = Array.isArray(a.moves) ? (a.moves as Record<string, unknown>[]) : [];
      const moves = raw.map((m) => ({ id: str(m.node), x: Number(m.x), y: Number(m.y) })).filter((m) => m.id && Number.isFinite(m.x) && Number.isFinite(m.y));
      if (!moves.length) return { error: "moves must be a list of {node, x, y} with numeric x and y" };
      const missing = useCanvasStore.getState().moveNodes(moves);
      requestFit();
      return { moved: moves.length - missing.length, ...(missing.length ? { missing } : {}) };
    },
  },
  auto_layout: {
    kind: "edit",
    spec: { type: "function", function: { name: "auto_layout", description: "Tidies the layout: arranges nodes left to right by data flow (inputs on the left, each node right of what feeds it), with no overlaps. One undoable step. Pass node ids to arrange only those (they keep their parent group); omit to arrange the whole workflow.", parameters: { type: "object", properties: { nodes: { type: "array", items: { type: "string" } } } } } },
    summarise: (a) => (Array.isArray(a.nodes) && a.nodes.length ? `Arrange ${a.nodes.length} node${a.nodes.length === 1 ? "" : "s"}` : "Tidy the layout"),
    run: async (a) => {
      const ids = Array.isArray(a.nodes) && a.nodes.length ? a.nodes.map(String) : undefined;
      const known = new Set(useCanvasStore.getState().nodes.map((n) => n.id));
      const missing = ids?.filter((id) => !known.has(id)) ?? [];
      const moved = useCanvasStore.getState().autoLayout(ids?.filter((id) => known.has(id)));
      requestFit();
      return { moved, ...(missing.length ? { missing } : {}) };
    },
  },
  run_workflow: {
    kind: "run",
    spec: { type: "function", function: { name: "run_workflow", description: "Queues a run. With targets, runs only those nodes and what feeds them. Spends the user's Hugging Face quota. Returns immediately; call get_workflow later to read statuses and outputs.", parameters: { type: "object", properties: { targets: { type: "array", items: { type: "string" } } } } } },
    summarise: (a) => (Array.isArray(a.targets) && a.targets.length ? `Run up to ${a.targets.map((t) => nodeLabel(String(t))).join(", ")}` : "Run the whole workflow"),
    run: async (a) => {
      const targets = Array.isArray(a.targets) && a.targets.length ? a.targets.map(String) : undefined;
      useCanvasStore.getState().enqueue(targets);
      return { queued: true };
    },
  },
};

function nodeLabel(id: string) {
  const n = useCanvasStore.getState().nodes.find((x) => x.id === id);
  return n ? `${n.data.title ?? getDef(n.data.defType).title} (${id})` : id;
}

export const TOOL_SPECS = Object.values(TOOLS).map((t) => t.spec);

export const SYSTEM_PROMPT = `You are the copilot inside a node-based workflow editor for the Hugging Face Hub. Every public Gradio Space can become a node: its API endpoints turn into node types whose input ports mirror the Space's parameters. Input nodes (in:text, in:image, in:audio, in:video, in:file, in:number, in:toggle) feed values in. Links go from an output port to an input port with a compatible type (image, audio, video, file, text, number, boolean, gallery, json; "any" matches anything). Outputs from one Space flow into the next as URLs.

How to work:
- Start by calling get_workflow so you know the current node ids and what is loaded.
- To use a Space, search_spaces, pick a running one (usable=true, most likes), then add_space to load its endpoints. Read the returned endpoint ports before adding nodes.
- Build graphs with add_node and connect. Use the node ids you get back. Set sensible widget values.
- Keep the canvas readable. After building or rewiring a workflow, call auto_layout once so it reads left to right with nothing overlapping. Use move_nodes for fine placement (positions and sizes come from get_workflow; x grows right, y grows down).
- Only run when the user asks; runs cost the user's quota. After run_workflow, call wait_for_run to see the outcome, then report it.
- When something fails or the user asks why, read get_run_status (log + per-node errors) and get_node on the failing node before answering. Quote the actual error text.
- Be brief. Say what you did in one or two sentences; no bullet-point recaps of tool calls. Ask a short question only when the request is truly ambiguous.
- Node types look like "space:owner/name/endpoint" or "in:text". Never invent types; use ones returned by tools.`;