From ed6792e378809b35ac6690534f2f2a2bab892c8f Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 07:30:39 -0400 Subject: [PATCH 01/10] feat(assistant): create Model Builder workflows Let the AI assistant author validated, editable processing graphs and open them in Model Builder without discarding unsaved canvas work. --- .../model-builder/ModelBuilderPanel.tsx | 12 ++ .../src/lib/assistant/agent.ts | 1 + .../src/lib/assistant/model-builder.ts | 144 ++++++++++++++++++ .../src/lib/assistant/tools.ts | 66 ++++++++ docs/user-guide/ai-assistant.md | 2 + packages/core/src/store.ts | 6 + tests/assistant-model-builder.test.ts | 134 ++++++++++++++++ 7 files changed, 365 insertions(+) create mode 100644 apps/geolibre-desktop/src/lib/assistant/model-builder.ts create mode 100644 tests/assistant-model-builder.test.ts diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index 339cfefdfb..eaf8701185 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -255,7 +255,9 @@ export function ModelBuilderPanel({ }: ModelBuilderPanelProps): ReactElement | null { const { t } = useTranslation(); const open = useAppStore((s) => s.ui.modelBuilderOpen); + const requestedModelId = useAppStore((s) => s.ui.modelBuilderRequestedModelId); const setOpen = useAppStore((s) => s.setModelBuilderOpen); + const setRequestedModelId = useAppStore((s) => s.setModelBuilderRequestedModelId); const layers = useAppStore((s) => s.layers); const savedModels = useAppStore((s) => s.models); const saveModel = useAppStore((s) => s.saveModel); @@ -502,6 +504,16 @@ export function ModelBuilderPanel({ [confirmDiscard, layoutOptions, resetRunState], ); + // Programmatic entry points (notably the AI Assistant) save a normal project + // model and request that it be shown. Reuse the regular load path so an + // unsaved canvas still receives its discard confirmation. + useEffect(() => { + if (!open || !requestedModelId) return; + const requested = savedModels.find((model) => model.id === requestedModelId); + setRequestedModelId(null); + if (requested) handleLoadModel(requested); + }, [open, requestedModelId, savedModels, setRequestedModelId, handleLoadModel]); + /** * Forget the loaded model. The picker only offers models the project already * holds, so the button is meaningful exactly when the open model is one of diff --git a/apps/geolibre-desktop/src/lib/assistant/agent.ts b/apps/geolibre-desktop/src/lib/assistant/agent.ts index 7c566f2d1d..87d9605909 100644 --- a/apps/geolibre-desktop/src/lib/assistant/agent.ts +++ b/apps/geolibre-desktop/src/lib/assistant/agent.ts @@ -19,6 +19,7 @@ Guidelines: - For data questions, prefer run_sql with a single read-only DuckDB Spatial SQL statement against the SQL table names from list_layers. Show the SQL you ran. Only add the result as a layer when the user asks to map it or when geometry is clearly wanted. - For styling requests, use apply_symbology with the layer's real field names. - For geoprocessing (buffer, clip, dissolve, intersection, difference, union, spatial join, simplify, centroids, H3 grids, …), call list_algorithms to discover ids and typed parameters, then run_algorithm with the algorithm id and parameters. A 'layer' parameter takes a layer id. Build a multi-step pipeline by feeding one run's returned result layer id into the next. +- When the user asks to create, design, or build a reusable Model Builder model, do not execute the pipeline immediately. Call list_model_algorithms, then create_model_builder_model to save a validated editable graph and open it for review. - To add satellite/aerial imagery or other earth-observation data, use search_stac and add_stac_layer against the Planetary Computer (collections such as sentinel-2-l2a, landsat-c2-l2, naip, cop-dem-glo-30); the bounding box defaults to the current view. - To add tile basemaps (OpenStreetMap, OpenTopoMap, CARTO Dark Matter, etc.), use add_tile_layer with a known name or an XYZ url, rather than asking the user or saying you cannot. - Use web_search when you need current information from the internet. diff --git a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts new file mode 100644 index 0000000000..0488cf8d2b --- /dev/null +++ b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts @@ -0,0 +1,144 @@ +import type { GeoLibreLayer, ProcessingModel, ProcessingModelGraph } from "@geolibre/core"; +import { + INPUT_NODE_PORT, + OUTPUT_NODE_PORT, + validateModelGraph, + graphToLinearSteps, + type ModelToolDescriptor, +} from "@geolibre/processing"; + +export interface AssistantModelInput { + key: string; + layer: string; +} + +export interface AssistantModelStep { + key: string; + algorithm: string; + parameters?: Record; + inputs: Record; +} + +export interface AssistantModelOutput { + source: string; + name: string; +} + +export interface AssistantModelDefinition { + name: string; + inputs: AssistantModelInput[]; + steps: AssistantModelStep[]; + outputs: AssistantModelOutput[]; +} + +/** Build and validate the graph requested by the assistant before it reaches the store. */ +export function buildAssistantModel( + definition: AssistantModelDefinition, + layers: GeoLibreLayer[], + descriptors: ModelToolDescriptor[], + createId: () => string = () => crypto.randomUUID(), +): ProcessingModel { + const descriptorById = new Map(descriptors.map((descriptor) => [descriptor.toolId, descriptor])); + const nodesByKey = new Map(); + const nodes: ProcessingModelGraph["nodes"] = []; + const edges: ProcessingModelGraph["edges"] = []; + + const claimKey = (key: string): void => { + if (!key.trim()) throw new Error("Every input and step needs a non-empty key."); + if (nodesByKey.has(key)) throw new Error(`Duplicate model key "${key}".`); + }; + const resolveLayer = (reference: string): GeoLibreLayer | undefined => { + const exactId = layers.find((layer) => layer.id === reference); + if (exactId) return exactId; + const target = reference.trim().toLowerCase(); + return layers.find((layer) => layer.name.toLowerCase() === target); + }; + + definition.inputs.forEach((input, index) => { + claimKey(input.key); + const layer = resolveLayer(input.layer); + if (!layer) throw new Error(`No layer matching model input "${input.layer}".`); + const id = createId(); + nodes.push({ id, kind: "input", layerId: layer.id, x: 0, y: index * 112 }); + nodesByKey.set(input.key, { id, outputPort: INPUT_NODE_PORT, kind: "input" }); + }); + + definition.steps.forEach((step, index) => { + claimKey(step.key); + const descriptor = descriptorById.get(step.algorithm); + if (!descriptor) throw new Error(`"${step.algorithm}" is not a Model Builder algorithm.`); + const inputPorts = new Map(descriptor.inputs.map((port) => [port.id, port])); + const parameters = { ...(step.parameters ?? {}) }; + const id = createId(); + for (const [portId, sourceKey] of Object.entries(step.inputs)) { + if (!inputPorts.has(portId)) { + throw new Error(`Algorithm "${step.algorithm}" has no input port "${portId}".`); + } + const source = nodesByKey.get(sourceKey); + if (!source) + throw new Error(`Model source "${sourceKey}" must be defined before "${step.key}".`); + delete parameters[portId]; + edges.push({ + id: createId(), + from: source.id, + fromPort: source.outputPort, + to: id, + toPort: portId, + }); + } + nodes.push({ + id, + kind: "tool", + provider: descriptor.provider, + toolId: descriptor.toolId, + parameters, + x: 260 + index * 260, + y: index * 32, + }); + nodesByKey.set(step.key, { + id, + outputPort: descriptor.outputs[0]?.id ?? "out", + kind: "tool", + }); + }); + + definition.outputs.forEach((output, index) => { + const source = nodesByKey.get(output.source); + if (!source) throw new Error(`Unknown model output source "${output.source}".`); + if (source.kind !== "tool") throw new Error("A model output must come from an algorithm step."); + const id = createId(); + nodes.push({ + id, + kind: "output", + name: output.name.trim() || "Model output", + x: 260 + definition.steps.length * 260, + y: index * 112, + }); + edges.push({ + id: createId(), + from: source.id, + fromPort: source.outputPort, + to: id, + toPort: OUTPUT_NODE_PORT, + }); + }); + + if (!definition.steps.length) throw new Error("A model needs at least one algorithm step."); + if (!definition.outputs.length) throw new Error("A model needs at least one output."); + const graph = { nodes, edges }; + const descriptorByKey = new Map( + descriptors.map((item) => [`${item.provider}:${item.toolId}`, item]), + ); + const issues = validateModelGraph(graph, (provider, toolId) => + provider && toolId ? descriptorByKey.get(`${provider}:${toolId}`) : undefined, + ); + if (issues.length) { + throw new Error(`Invalid model: ${issues.map((issue) => issue.code).join(", ")}.`); + } + return { + id: createId(), + name: definition.name.trim() || "AI-created model", + graph, + steps: graphToLinearSteps(graph), + }; +} diff --git a/apps/geolibre-desktop/src/lib/assistant/tools.ts b/apps/geolibre-desktop/src/lib/assistant/tools.ts index ae67af1603..210074226a 100644 --- a/apps/geolibre-desktop/src/lib/assistant/tools.ts +++ b/apps/geolibre-desktop/src/lib/assistant/tools.ts @@ -17,6 +17,7 @@ import { cleanStatement, maskSqlLiterals, previewLayerTables, runSqlQuery } from import { createXyzTileUrlTemplate } from "../xyz-url"; import { findNamedTileBasemap, NAMED_TILE_BASEMAPS } from "./basemaps"; import { buildSymbologyStyle } from "./symbology"; +import { buildAssistantModel } from "./model-builder"; import { webSearch } from "./web-search"; /** Dependencies the assistant tools need beyond the global store. */ @@ -694,6 +695,69 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool { + const [{ VECTOR_TOOLS }, { vectorToolDescriptor }] = await Promise.all([ + import("@geolibre/processing"), + import("../model-tool-catalog"), + ]); + return json({ + algorithms: VECTOR_TOOLS.map(vectorToolDescriptor).map((descriptor) => ({ + id: descriptor.toolId, + name: descriptor.name, + description: descriptor.description, + inputs: descriptor.inputs, + parameters: descriptor.parameters, + outputs: descriptor.outputs, + })), + }); + }, + }); + + const createModelBuilderModel = tool({ + name: "create_model_builder_model", + description: + "Create, save, and open an editable Model Builder workflow. Inputs and steps use unique short keys. Each step's inputs maps an algorithm input-port id to an earlier input/step key; parameters holds non-layer settings. Outputs name results to add to the map. Call list_model_algorithms first and use its exact ids.", + inputSchema: z.object({ + name: z.string(), + inputs: z.array(z.object({ key: z.string(), layer: z.string() })), + steps: z.array( + z.object({ + key: z.string(), + algorithm: z.string(), + parameters: z.record(z.string(), z.unknown()).optional(), + inputs: z.record(z.string(), z.string()), + }), + ), + outputs: z.array(z.object({ source: z.string(), name: z.string() })), + }), + callback: async (input) => { + const [{ VECTOR_TOOLS }, { vectorToolDescriptor }] = await Promise.all([ + import("@geolibre/processing"), + import("../model-tool-catalog"), + ]); + const model = buildAssistantModel( + input, + store().layers, + VECTOR_TOOLS.map(vectorToolDescriptor), + ); + store().saveModel(model); + store().setModelBuilderRequestedModelId(model.id); + store().setModelBuilderOpen(true); + return json({ + modelId: model.id, + name: model.name, + nodes: model.graph?.nodes.length ?? 0, + edges: model.graph?.edges.length ?? 0, + opened: true, + }); + }, + }); + const searchStac = tool({ name: "search_stac", description: @@ -810,6 +874,8 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool void; setBatchToolsOpen: (open: boolean) => void; setModelBuilderOpen: (open: boolean) => void; + setModelBuilderRequestedModelId: (id: string | null) => void; setProcessingHistoryOpen: (open: boolean) => void; /** Open/close Select by Expression, optionally preselecting a target layer. */ setSelectByExpressionOpen: (open: boolean, layerId?: string | null) => void; @@ -1064,6 +1067,7 @@ export const useAppStore = create()( storymapComposingId: null, batchToolsOpen: false, modelBuilderOpen: false, + modelBuilderRequestedModelId: null, styleManagerOpen: false, processingHistoryOpen: false, selectByExpressionOpen: false, @@ -1401,6 +1405,8 @@ export const useAppStore = create()( set((s) => ({ ui: { ...s.ui, storymapComposingId: chapterId } })), setBatchToolsOpen: (open) => set((s) => ({ ui: { ...s.ui, batchToolsOpen: open } })), setModelBuilderOpen: (open) => set((s) => ({ ui: { ...s.ui, modelBuilderOpen: open } })), + setModelBuilderRequestedModelId: (id) => + set((s) => ({ ui: { ...s.ui, modelBuilderRequestedModelId: id } })), setProcessingHistoryOpen: (open) => set((s) => ({ ui: { ...s.ui, processingHistoryOpen: open } })), setProcessingRerun: (request) => set((s) => ({ ui: { ...s.ui, processingRerun: request } })), diff --git a/tests/assistant-model-builder.test.ts b/tests/assistant-model-builder.test.ts new file mode 100644 index 0000000000..b8ca00d804 --- /dev/null +++ b/tests/assistant-model-builder.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { GeoLibreLayer } from "../packages/core/src/types"; +import type { ModelToolDescriptor } from "../packages/processing/src/model-graph"; +import { buildAssistantModel } from "../apps/geolibre-desktop/src/lib/assistant/model-builder"; + +const BUFFER: ModelToolDescriptor = { + key: "vector:buffer", + provider: "vector", + toolId: "buffer", + name: "Buffer", + group: "Geometry", + inputs: [{ id: "layer", label: "Input", kind: "vector", required: true }], + outputs: [{ id: "out", label: "Output", kind: "vector" }], + parameters: [{ id: "distance", label: "Distance", type: "number", required: true }], +}; + +const CLIP: ModelToolDescriptor = { + key: "vector:clip", + provider: "vector", + toolId: "clip", + name: "Clip", + group: "Overlay", + inputs: [ + { id: "layer", label: "Input", kind: "vector", required: true }, + { id: "overlay", label: "Overlay", kind: "vector", required: true }, + ], + outputs: [{ id: "out", label: "Output", kind: "vector" }], + parameters: [], +}; + +const layers = [ + { id: "roads-id", name: "Roads", type: "geojson" }, + { id: "counties-id", name: "Counties", type: "geojson" }, +] as GeoLibreLayer[]; + +function ids(): () => string { + let next = 0; + return () => `id-${++next}`; +} + +describe("AI-created Model Builder models", () => { + it("turns a natural-language-style pipeline definition into a saved graph", () => { + const model = buildAssistantModel( + { + name: "Road buffers clipped to counties", + inputs: [ + { key: "roads", layer: "Roads" }, + { key: "counties", layer: "counties-id" }, + ], + steps: [ + { + key: "buffered", + algorithm: "buffer", + inputs: { layer: "roads" }, + parameters: { distance: 100 }, + }, + { + key: "clipped", + algorithm: "clip", + inputs: { layer: "buffered", overlay: "counties" }, + }, + ], + outputs: [{ source: "clipped", name: "Clipped road buffers" }], + }, + layers, + [BUFFER, CLIP], + ids(), + ); + + assert.equal(model.name, "Road buffers clipped to counties"); + assert.equal(model.graph?.nodes.length, 5); + assert.equal(model.graph?.edges.length, 4); + // A two-input clip is a graph rather than a legacy linear model. + assert.deepEqual(model.steps, []); + const toolNodes = model.graph?.nodes.filter((node) => node.kind === "tool") ?? []; + assert.deepEqual( + toolNodes.map((node) => [node.toolId, node.parameters]), + [ + ["buffer", { distance: 100 }], + ["clip", {}], + ], + ); + }); + + it("rejects unknown algorithms, ports, sources, and layers", () => { + const base = { + name: "Bad model", + inputs: [{ key: "roads", layer: "Roads" }], + steps: [{ key: "result", algorithm: "buffer", inputs: { layer: "roads" } }], + outputs: [{ source: "result", name: "Result" }], + }; + assert.throws( + () => + buildAssistantModel( + { ...base, inputs: [{ key: "x", layer: "Missing" }] }, + layers, + [BUFFER], + ids(), + ), + /No layer matching/, + ); + assert.throws( + () => + buildAssistantModel( + { ...base, steps: [{ ...base.steps[0], algorithm: "invented" }] }, + layers, + [BUFFER], + ids(), + ), + /not a Model Builder algorithm/, + ); + assert.throws( + () => + buildAssistantModel( + { ...base, steps: [{ ...base.steps[0], inputs: { bogus: "roads" } }] }, + layers, + [BUFFER], + ids(), + ), + /no input port/, + ); + assert.throws( + () => + buildAssistantModel( + { ...base, outputs: [{ source: "missing", name: "Result" }] }, + layers, + [BUFFER], + ids(), + ), + /Unknown model output source/, + ); + }); +}); From 5a074142a6b575a1711056c403e6f6eadc0c97be Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 07:49:34 -0400 Subject: [PATCH 02/10] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `buildAssistantModel` keyed its descriptor map on the bare `toolId`, but `modelToolKey` makes the provider part of a tool's identity because the Whitebox and client vector registries both define ids like `buffer`. Resolve a step's algorithm through a `provider:toolId` map, accept a bare id only while exactly one provider claims it, and reject a collision instead of silently picking whichever descriptor came last. `list_model_algorithms` now reports each algorithm's `provider` so the model can qualify an id. - `confirmDiscard` only checked the `dirty` flag, so a saved, unmodified model that was actively running could be aborted with no prompt — reachable now that the assistant can request a model load the user never clicked. Prompt separately when a run is in flight; adds `processing.modelBuilder.discardRunning` to every locale. - `validateModelGraph` inspects input ports but not parameters, so an invented parameter id, a missing required setting, or a string where a number belongs reached `saveModel` verbatim. Check each step's parameters against its descriptor (honoring `visibleWhen` and defaults) before the node is built. - `list_model_algorithms` and `create_model_builder_model` duplicated the dynamic-import + `VECTOR_TOOLS.map(vectorToolDescriptor)` sequence; both now read one `loadModelToolDescriptors` helper so the ids offered to the model and the ids resolved cannot drift. - Tests cover provider-qualified resolution, the ambiguous-id rejection, and the three parameter failure modes. --- .../model-builder/ModelBuilderPanel.tsx | 14 ++- .../geolibre-desktop/src/i18n/locales/ar.json | 1 + .../geolibre-desktop/src/i18n/locales/de.json | 1 + .../geolibre-desktop/src/i18n/locales/en.json | 1 + .../geolibre-desktop/src/i18n/locales/es.json | 1 + .../geolibre-desktop/src/i18n/locales/fa.json | 1 + .../geolibre-desktop/src/i18n/locales/fr.json | 1 + .../geolibre-desktop/src/i18n/locales/hi.json | 1 + .../geolibre-desktop/src/i18n/locales/id.json | 1 + .../geolibre-desktop/src/i18n/locales/it.json | 1 + .../geolibre-desktop/src/i18n/locales/ja.json | 1 + .../geolibre-desktop/src/i18n/locales/ka.json | 1 + .../geolibre-desktop/src/i18n/locales/ko.json | 1 + .../geolibre-desktop/src/i18n/locales/nl.json | 1 + .../geolibre-desktop/src/i18n/locales/pt.json | 1 + .../geolibre-desktop/src/i18n/locales/ru.json | 1 + .../geolibre-desktop/src/i18n/locales/th.json | 1 + .../geolibre-desktop/src/i18n/locales/tr.json | 1 + .../geolibre-desktop/src/i18n/locales/vi.json | 1 + .../geolibre-desktop/src/i18n/locales/zh.json | 1 + .../src/lib/assistant/model-builder.ts | 112 +++++++++++++++++- .../src/lib/assistant/tools.ts | 37 +++--- tests/assistant-model-builder.test.ts | 96 ++++++++++++++- 23 files changed, 253 insertions(+), 25 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index eaf8701185..fa6783d9e0 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -466,11 +466,17 @@ export function ModelBuilderPanel({ * the current model away, so each asks first rather than discarding unsaved * work silently. `window.confirm` is blocking and matches how the rest of * the app confirms a discard (see PythonEditorPane). + * + * A run in flight is unsaved work of a different kind — `resetRunState` + * aborts it — and a saved, unmodified model is not `dirty`, so it needs its + * own prompt. That matters most for the AI Assistant, which can request a + * model load the user never clicked; without this a background trigger would + * kill a running job with no confirmation at all. */ - const confirmDiscard = useCallback( - () => !dirty || window.confirm(t("processing.modelBuilder.discardChanges")), - [dirty, t], - ); + const confirmDiscard = useCallback(() => { + if (running && !window.confirm(t("processing.modelBuilder.discardRunning"))) return false; + return !dirty || window.confirm(t("processing.modelBuilder.discardChanges")); + }, [dirty, running, t]); /** * Room the layout gets to work with: the canvas's own visible width, so a diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 9ba3fad5a4..3e8c343b4d 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -4319,6 +4319,7 @@ "untitledModel": "نموذج بلا عنوان", "newModel": "جديد", "discardChanges": "هل تريد تجاهل التغييرات غير المحفوظة في النموذج الحالي؟", + "discardRunning": "لا يزال تشغيل النموذج جاريًا. هل تريد إيقافه والمتابعة؟", "arrange": "ترتيب", "arrangeHint": "ترتيب العقد على امتداد مسار التدفق", "runModel": "تشغيل", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index 82aea2bb90..b18188ca88 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -4052,6 +4052,7 @@ "untitledModel": "Unbenanntes Modell", "newModel": "Neu", "discardChanges": "Nicht gespeicherte Änderungen am aktuellen Modell verwerfen?", + "discardRunning": "Ein Modelllauf läuft noch. Anhalten und fortfahren?", "arrange": "Anordnen", "arrangeHint": "Die Knoten entlang des Ablaufs anordnen", "runModel": "Ausführen", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 7c4d6f2182..628161f204 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -4062,6 +4062,7 @@ "untitledModel": "Untitled model", "newModel": "New", "discardChanges": "Discard unsaved changes to the current model?", + "discardRunning": "A model run is still in progress. Stop it and continue?", "arrange": "Arrange", "arrangeHint": "Lay the nodes out along the flow", "runModel": "Run", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index b3214b3126..c8aba76d8f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -4052,6 +4052,7 @@ "untitledModel": "Modelo sin título", "newModel": "Nuevo", "discardChanges": "¿Descartar los cambios sin guardar del modelo actual?", + "discardRunning": "Todavía se está ejecutando un modelo. ¿Detenerlo y continuar?", "arrange": "Organizar", "arrangeHint": "Distribuir los nodos siguiendo el flujo", "runModel": "Ejecutar", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 28ec224249..e078edf937 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -4052,6 +4052,7 @@ "untitledModel": "مدل بدون عنوان", "newModel": "جدید", "discardChanges": "تغییرات ذخیره‌نشدهٔ مدل کنونی دور انداخته شوند؟", + "discardRunning": "اجرای مدل هنوز در جریان است. متوقف شود و ادامه دهیم؟", "arrange": "چیدمان", "arrangeHint": "چیدن گره‌ها در امتداد جریان", "runModel": "اجرا", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 87a3eed93e..233635fa59 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -4052,6 +4052,7 @@ "untitledModel": "Modèle sans titre", "newModel": "Nouveau", "discardChanges": "Annuler les modifications non enregistrées du modèle actuel ?", + "discardRunning": "Une exécution du modèle est encore en cours. L’arrêter et continuer ?", "arrange": "Organiser", "arrangeHint": "Disposer les nœuds le long du flux", "runModel": "Exécuter", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index b4c5dc8083..32aa8ee7b8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -4052,6 +4052,7 @@ "untitledModel": "बिना शीर्षक मॉडल", "newModel": "नया", "discardChanges": "वर्तमान मॉडल में असेव्ड बदलाव त्यागें?", + "discardRunning": "मॉडल अभी भी चल रहा है। इसे रोककर आगे बढ़ें?", "arrange": "व्यवस्थित करें", "arrangeHint": "नोड्स को प्रवाह के अनुसार व्यवस्थित करें", "runModel": "चलाएँ", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index 1edda158e8..7b4dc5f8c4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -3985,6 +3985,7 @@ "untitledModel": "Model tanpa judul", "newModel": "Baru", "discardChanges": "Buang perubahan yang belum disimpan pada model saat ini?", + "discardRunning": "Model masih berjalan. Hentikan dan lanjutkan?", "arrange": "Tata", "arrangeHint": "Menata simpul mengikuti alur", "runModel": "Jalankan", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 4877694d49..14cb0d659c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -4052,6 +4052,7 @@ "untitledModel": "Modello senza titolo", "newModel": "Nuovo", "discardChanges": "Scartare le modifiche non salvate del modello corrente?", + "discardRunning": "Un'esecuzione del modello è ancora in corso. Interromperla e continuare?", "arrange": "Disponi", "arrangeHint": "Dispone i nodi lungo il flusso", "runModel": "Esegui", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index a8fd6521eb..6237fde646 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -3985,6 +3985,7 @@ "untitledModel": "名称未設定のモデル", "newModel": "新規", "discardChanges": "現在のモデルの未保存の変更を破棄しますか?", + "discardRunning": "モデルの実行がまだ進行中です。停止して続行しますか?", "arrange": "整列", "arrangeHint": "ノードを処理の流れに沿って並べます", "runModel": "実行", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index 8de41c14b2..324a6fc908 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -4052,6 +4052,7 @@ "untitledModel": "უსათაურო მოდელი", "newModel": "ახალი", "discardChanges": "უარვყოთ მიმდინარე მოდელის შეუნახავი ცვლილებები?", + "discardRunning": "მოდელის გაშვება ჯერ კიდევ მიმდინარეობს. შევაჩეროთ და გავაგრძელოთ?", "arrange": "დალაგება", "arrangeHint": "კვანძების დალაგება ნაკადის მიმართულებით", "runModel": "გაშვება", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index 7af105351d..fc6886f55e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -3985,6 +3985,7 @@ "untitledModel": "제목 없는 모델", "newModel": "새로 만들기", "discardChanges": "현재 모델의 저장되지 않은 변경 사항을 취소하시겠습니까?", + "discardRunning": "모델 실행이 아직 진행 중입니다. 중지하고 계속하시겠습니까?", "arrange": "정렬", "arrangeHint": "노드를 처리 흐름에 따라 배치합니다", "runModel": "실행", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index d8cf7786b6..0e85edc74f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -4052,6 +4052,7 @@ "untitledModel": "Naamloos model", "newModel": "Nieuw", "discardChanges": "Niet-opgeslagen wijzigingen aan het huidige model verwerpen?", + "discardRunning": "Er wordt nog een model uitgevoerd. Stoppen en doorgaan?", "arrange": "Ordenen", "arrangeHint": "De knooppunten langs de stroom ordenen", "runModel": "Uitvoeren", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index b3fa92c910..ce4cfe5aa7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -4052,6 +4052,7 @@ "untitledModel": "Modelo sem título", "newModel": "Novo", "discardChanges": "Descartar alterações não guardadas no modelo atual?", + "discardRunning": "Ainda está a decorrer uma execução do modelo. Parar e continuar?", "arrange": "Organizar", "arrangeHint": "Dispor os nós ao longo do fluxo", "runModel": "Executar", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 47dc24524f..6bbde715a3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -4186,6 +4186,7 @@ "untitledModel": "Модель без названия", "newModel": "Создать", "discardChanges": "Отменить несохранённые изменения текущей модели?", + "discardRunning": "Выполнение модели ещё не завершено. Остановить и продолжить?", "arrange": "Упорядочить", "arrangeHint": "Расставить узлы вдоль потока обработки", "runModel": "Запустить", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 7f8907a9ec..2b9c6c66e8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -3985,6 +3985,7 @@ "untitledModel": "แบบจำลองไม่มีชื่อ", "newModel": "ใหม่", "discardChanges": "ทิ้งการเปลี่ยนแปลงที่ยังไม่ได้บันทึกของแบบจำลองปัจจุบันหรือไม่?", + "discardRunning": "การเรียกใช้แบบจำลองยังดำเนินอยู่ ต้องการหยุดและดำเนินการต่อหรือไม่?", "arrange": "จัดเรียง", "arrangeHint": "จัดเรียงโหนดตามลำดับการไหลของงาน", "runModel": "เรียกใช้", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 79f8ee0fdb..3c6db29a86 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -4052,6 +4052,7 @@ "untitledModel": "Adsız model", "newModel": "Yeni", "discardChanges": "Mevcut modelin kaydedilmemiş değişiklikleri atılsın mı?", + "discardRunning": "Bir model çalışması hâlâ sürüyor. Durdurulup devam edilsin mi?", "arrange": "Düzenle", "arrangeHint": "Düğümleri akış boyunca dizer", "runModel": "Çalıştır", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index cc606f9547..478dceeff7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -4071,6 +4071,7 @@ "untitledModel": "Mô hình chưa đặt tên", "newModel": "Mới", "discardChanges": "Hủy các thay đổi chưa được lưu đối với mô hình hiện tại?", + "discardRunning": "Một lần chạy mô hình vẫn đang diễn ra. Dừng lại và tiếp tục?", "arrange": "Sắp xếp", "arrangeHint": "Sắp xếp các nút theo dòng xử lý", "runModel": "Chạy", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index 42cd776c22..18753618a7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -3985,6 +3985,7 @@ "untitledModel": "未命名模型", "newModel": "新建", "discardChanges": "是否放弃当前模型的未保存更改?", + "discardRunning": "模型运行尚未结束。是否停止并继续?", "arrange": "排列", "arrangeHint": "沿处理流程排列节点", "runModel": "运行", diff --git a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts index 0488cf8d2b..eb4c0754d1 100644 --- a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts +++ b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts @@ -4,6 +4,7 @@ import { OUTPUT_NODE_PORT, validateModelGraph, graphToLinearSteps, + type AlgorithmParameter, type ModelToolDescriptor, } from "@geolibre/processing"; @@ -31,6 +32,78 @@ export interface AssistantModelDefinition { outputs: AssistantModelOutput[]; } +/** Whether a parameter applies, given the other values the assistant supplied. */ +function isParameterVisible(param: AlgorithmParameter, values: Record): boolean { + const vw = param.visibleWhen; + if (!vw) return true; + const current = values[vw.param] as string | undefined; + if ("in" in vw) return current != null && vw.in.includes(current); + return current == null || !vw.notIn.includes(current); +} + +/** Whether a value is usable for a parameter of this declared type. */ +function parameterTypeMatches(param: AlgorithmParameter, value: unknown): boolean { + switch (param.type) { + case "number": + return typeof value === "number" && Number.isFinite(value); + case "boolean": + return typeof value === "boolean"; + case "select": + return ( + typeof value === "string" && + (!param.options?.length || param.options.some((option) => option.value === value)) + ); + default: + // layer / string / field / path all arrive as text. + return typeof value === "string"; + } +} + +/** + * Check a step's parameter block against the tool's own declaration. + * + * `validateModelGraph` only inspects input *ports*, so without this an invented + * parameter id, a missing required setting, or a string where a number belongs + * would be saved verbatim and only surface when the user presses Run. + * + * @param step The step as the assistant described it, used for error text. + * @param descriptor The tool the step resolved to. + * @param values The parameters left after edge-supplied ports were removed. + * @param wired Ids of the input ports an edge already feeds. + */ +function checkStepParameters( + step: AssistantModelStep, + descriptor: ModelToolDescriptor, + values: Record, + wired: Set, +): void { + const declared = new Map(descriptor.parameters.map((param) => [param.id, param])); + // A layer parameter doubles as an input port, so a port id stays a legal key + // even when the registry did not also list it among the parameters. + const portIds = new Set(descriptor.inputs.map((port) => port.id)); + for (const [id, value] of Object.entries(values)) { + const param = declared.get(id); + if (!param) { + if (portIds.has(id)) continue; + throw new Error(`Algorithm "${step.algorithm}" has no parameter "${id}".`); + } + if (value === undefined || value === null) continue; + if (!parameterTypeMatches(param, value)) { + throw new Error(`Parameter "${id}" of "${step.algorithm}" expects a ${param.type} value.`); + } + } + for (const param of descriptor.parameters) { + // A wired port carries its value along the edge, and a parameter the tool + // defaults needs no explicit value. + if (!param.required || wired.has(param.id) || param.default !== undefined) continue; + if (!isParameterVisible(param, values)) continue; + const value = values[param.id]; + if (value === undefined || value === null || value === "") { + throw new Error(`Parameter "${param.id}" of "${step.algorithm}" is required.`); + } + } +} + /** Build and validate the graph requested by the assistant before it reaches the store. */ export function buildAssistantModel( definition: AssistantModelDefinition, @@ -38,7 +111,35 @@ export function buildAssistantModel( descriptors: ModelToolDescriptor[], createId: () => string = () => crypto.randomUUID(), ): ProcessingModel { - const descriptorById = new Map(descriptors.map((descriptor) => [descriptor.toolId, descriptor])); + const descriptorByKey = new Map( + descriptors.map((descriptor) => [`${descriptor.provider}:${descriptor.toolId}`, descriptor]), + ); + /** + * A bare tool id only identifies a tool while exactly one provider claims it + * — Whitebox and the client vector registry both define e.g. `buffer`, which + * is why `modelToolKey` makes the provider part of a tool's identity. Mark a + * colliding id so it is reported rather than silently resolved to whichever + * descriptor happened to come last. + */ + const descriptorByToolId = new Map(); + for (const descriptor of descriptors) { + descriptorByToolId.set( + descriptor.toolId, + descriptorByToolId.has(descriptor.toolId) ? null : descriptor, + ); + } + const resolveDescriptor = (algorithm: string): ModelToolDescriptor => { + const qualified = descriptorByKey.get(algorithm); + if (qualified) return qualified; + const bare = descriptorByToolId.get(algorithm); + if (bare === null) { + throw new Error( + `"${algorithm}" is defined by more than one provider; name it as ":${algorithm}".`, + ); + } + if (!bare) throw new Error(`"${algorithm}" is not a Model Builder algorithm.`); + return bare; + }; const nodesByKey = new Map(); const nodes: ProcessingModelGraph["nodes"] = []; const edges: ProcessingModelGraph["edges"] = []; @@ -65,10 +166,10 @@ export function buildAssistantModel( definition.steps.forEach((step, index) => { claimKey(step.key); - const descriptor = descriptorById.get(step.algorithm); - if (!descriptor) throw new Error(`"${step.algorithm}" is not a Model Builder algorithm.`); + const descriptor = resolveDescriptor(step.algorithm); const inputPorts = new Map(descriptor.inputs.map((port) => [port.id, port])); const parameters = { ...(step.parameters ?? {}) }; + const wired = new Set(); const id = createId(); for (const [portId, sourceKey] of Object.entries(step.inputs)) { if (!inputPorts.has(portId)) { @@ -78,6 +179,7 @@ export function buildAssistantModel( if (!source) throw new Error(`Model source "${sourceKey}" must be defined before "${step.key}".`); delete parameters[portId]; + wired.add(portId); edges.push({ id: createId(), from: source.id, @@ -86,6 +188,7 @@ export function buildAssistantModel( toPort: portId, }); } + checkStepParameters(step, descriptor, parameters, wired); nodes.push({ id, kind: "tool", @@ -126,9 +229,6 @@ export function buildAssistantModel( if (!definition.steps.length) throw new Error("A model needs at least one algorithm step."); if (!definition.outputs.length) throw new Error("A model needs at least one output."); const graph = { nodes, edges }; - const descriptorByKey = new Map( - descriptors.map((item) => [`${item.provider}:${item.toolId}`, item]), - ); const issues = validateModelGraph(graph, (provider, toolId) => provider && toolId ? descriptorByKey.get(`${provider}:${toolId}`) : undefined, ); diff --git a/apps/geolibre-desktop/src/lib/assistant/tools.ts b/apps/geolibre-desktop/src/lib/assistant/tools.ts index 210074226a..181b082116 100644 --- a/apps/geolibre-desktop/src/lib/assistant/tools.ts +++ b/apps/geolibre-desktop/src/lib/assistant/tools.ts @@ -5,6 +5,7 @@ import { type GeoLibreLayer, } from "@geolibre/core"; import type { MapController } from "@geolibre/map"; +import type { ModelToolDescriptor } from "@geolibre/processing"; import type { InvokableTool, JSONValue } from "@strands-agents/sdk"; import * as maplibregl from "maplibre-gl"; import { tool } from "@strands-agents/sdk"; @@ -49,6 +50,22 @@ interface LayerSummary { fields: { name: string; type: string }[]; } +/** + * The algorithms the assistant may place in a Model Builder graph. + * + * `list_model_algorithms` and `create_model_builder_model` both read this one + * list, so the ids offered to the model and the ids `buildAssistantModel` + * resolves cannot drift apart. Imported dynamically to keep the processing + * registry out of the assistant's initial chunk. + */ +async function loadModelToolDescriptors(): Promise { + const [{ VECTOR_TOOLS }, { vectorToolDescriptor }] = await Promise.all([ + import("@geolibre/processing"), + import("../model-tool-catalog"), + ]); + return VECTOR_TOOLS.map(vectorToolDescriptor); +} + /** Statement keywords that write data or have side effects. */ const SQL_WRITE_KEYWORDS = /\b(INSERT|UPDATE|DELETE|MERGE|CREATE|DROP|ALTER|TRUNCATE|REPLACE|ATTACH|DETACH|COPY|EXPORT|IMPORT|INSTALL|LOAD|PRAGMA|VACUUM|CHECKPOINT)\b/; @@ -701,13 +718,13 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool { - const [{ VECTOR_TOOLS }, { vectorToolDescriptor }] = await Promise.all([ - import("@geolibre/processing"), - import("../model-tool-catalog"), - ]); return json({ - algorithms: VECTOR_TOOLS.map(vectorToolDescriptor).map((descriptor) => ({ + algorithms: (await loadModelToolDescriptors()).map((descriptor) => ({ id: descriptor.toolId, + // The registry a tool came from is part of its identity: two + // registries can define the same id, so a step naming a colliding + // bare id is rejected until it is qualified as `provider:id`. + provider: descriptor.provider, name: descriptor.name, description: descriptor.description, inputs: descriptor.inputs, @@ -736,15 +753,7 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool { - const [{ VECTOR_TOOLS }, { vectorToolDescriptor }] = await Promise.all([ - import("@geolibre/processing"), - import("../model-tool-catalog"), - ]); - const model = buildAssistantModel( - input, - store().layers, - VECTOR_TOOLS.map(vectorToolDescriptor), - ); + const model = buildAssistantModel(input, store().layers, await loadModelToolDescriptors()); store().saveModel(model); store().setModelBuilderRequestedModelId(model.id); store().setModelBuilderOpen(true); diff --git a/tests/assistant-model-builder.test.ts b/tests/assistant-model-builder.test.ts index b8ca00d804..c1306fe617 100644 --- a/tests/assistant-model-builder.test.ts +++ b/tests/assistant-model-builder.test.ts @@ -29,6 +29,18 @@ const CLIP: ModelToolDescriptor = { parameters: [], }; +/** A same-id tool from the other registry, the collision `modelToolKey` guards. */ +const WHITEBOX_BUFFER: ModelToolDescriptor = { + key: "whitebox:buffer", + provider: "whitebox", + toolId: "buffer", + name: "Buffer Raster", + group: "Whitebox", + inputs: [{ id: "input", label: "Input", kind: "raster", required: true }], + outputs: [{ id: "out", label: "Output", kind: "raster" }], + parameters: [], +}; + const layers = [ { id: "roads-id", name: "Roads", type: "geojson" }, { id: "counties-id", name: "Counties", type: "geojson" }, @@ -87,7 +99,14 @@ describe("AI-created Model Builder models", () => { const base = { name: "Bad model", inputs: [{ key: "roads", layer: "Roads" }], - steps: [{ key: "result", algorithm: "buffer", inputs: { layer: "roads" } }], + steps: [ + { + key: "result", + algorithm: "buffer", + inputs: { layer: "roads" }, + parameters: { distance: 100 }, + }, + ], outputs: [{ source: "result", name: "Result" }], }; assert.throws( @@ -131,4 +150,79 @@ describe("AI-created Model Builder models", () => { /Unknown model output source/, ); }); + + it("keeps the provider part of a tool's identity", () => { + const base = { + name: "Colliding ids", + inputs: [{ key: "roads", layer: "Roads" }], + steps: [ + { + key: "result", + algorithm: "buffer", + inputs: { layer: "roads" }, + parameters: { distance: 100 }, + }, + ], + outputs: [{ source: "result", name: "Result" }], + }; + // A bare id both registries define resolves to neither. + assert.throws( + () => buildAssistantModel(base, layers, [BUFFER, WHITEBOX_BUFFER], ids()), + /defined by more than one provider/, + ); + const model = buildAssistantModel( + { ...base, steps: [{ ...base.steps[0], algorithm: "vector:buffer" }] }, + layers, + [BUFFER, WHITEBOX_BUFFER], + ids(), + ); + const tool = model.graph?.nodes.find((node) => node.kind === "tool"); + assert.equal(tool?.provider, "vector"); + }); + + it("checks step parameters against the tool's own declaration", () => { + const base = { + name: "Bad parameters", + inputs: [{ key: "roads", layer: "Roads" }], + steps: [ + { + key: "result", + algorithm: "buffer", + inputs: { layer: "roads" }, + parameters: { distance: 100 } as Record, + }, + ], + outputs: [{ source: "result", name: "Result" }], + }; + assert.throws( + () => + buildAssistantModel( + { ...base, steps: [{ ...base.steps[0], parameters: { distance: 100, invented: 1 } }] }, + layers, + [BUFFER], + ids(), + ), + /has no parameter "invented"/, + ); + assert.throws( + () => + buildAssistantModel( + { ...base, steps: [{ ...base.steps[0], parameters: { distance: "far" } }] }, + layers, + [BUFFER], + ids(), + ), + /expects a number value/, + ); + assert.throws( + () => + buildAssistantModel( + { ...base, steps: [{ ...base.steps[0], parameters: {} }] }, + layers, + [BUFFER], + ids(), + ), + /"distance" of "buffer" is required/, + ); + }); }); From f59fe41e132173ab1231ee3f04d382d158d3c60e Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 07:57:21 -0400 Subject: [PATCH 03/10] Address Claude review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `create_model_builder_model` returned `opened: true` unconditionally, but the panel loads the model from an effect that first asks about unsaved canvas work or a run in flight — an answer that arrives long after the tool result. Report `saved` and `builderOpened` instead, and say in the tool description that Model Builder asks before replacing that work, so the assistant cannot claim an outcome the user declined. - The model tools only expose the client vector registry, so a request naming a raster/Whitebox tool would fail to find an algorithm with no explanation. Scope both tool descriptions and the user-guide line to vector, and point at run_algorithm / the Processing menu for the rest. - Describe the `layer` field as taking a layer id or exact name, matching apply_symbology. --- .../src/lib/assistant/tools.ts | 18 ++++++++++++++---- docs/user-guide/ai-assistant.md | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/assistant/tools.ts b/apps/geolibre-desktop/src/lib/assistant/tools.ts index 181b082116..d3c8f6579c 100644 --- a/apps/geolibre-desktop/src/lib/assistant/tools.ts +++ b/apps/geolibre-desktop/src/lib/assistant/tools.ts @@ -715,7 +715,7 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool { return json({ @@ -738,10 +738,15 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool Date: Thu, 20 Aug 2026 08:03:11 -0400 Subject: [PATCH 04/10] Address CodeRabbit review feedback - The user-guide Model Builder row said raster and Whitebox tools are unavailable without saying where to run them. Point at asking the assistant to run one directly or at the Processing menu, matching the scope the tool descriptions now state. --- docs/user-guide/ai-assistant.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/ai-assistant.md b/docs/user-guide/ai-assistant.md index 02a0a7005f..237e875e15 100644 --- a/docs/user-guide/ai-assistant.md +++ b/docs/user-guide/ai-assistant.md @@ -165,7 +165,7 @@ operations, so its actions stay within GeoLibre's validated surface. | **Inspect layers** | Lists loaded layers, their geometry, attribute fields, and SQL table names (schema only — never your full data). | | **NL → Spatial SQL** | Generates and runs a **read-only** DuckDB Spatial SQL query through the [SQL Workspace](sql-workspace.md), and can add the result as a layer. | | **Geoprocessing** | Runs the registered [processing](processing.md) algorithms (buffer, clip, dissolve, intersection, difference, union, spatial join, simplify, H3 grids, …) and chains them into multi-step pipelines, adding each result as a layer. | -| **Model Builder** | Creates a validated, editable Model Builder workflow from a description using the client-side vector algorithms (raster and Whitebox tools are not available here), saves it with the project, and opens it for review before you run it. If the canvas holds unsaved work or a run is still in flight, Model Builder asks before replacing it. | +| **Model Builder** | Creates a validated, editable Model Builder workflow from a description using the client-side vector algorithms, saves it with the project, and opens it for review before you run it. Raster and Whitebox tools cannot be placed in an assistant-built model; ask the assistant to run one directly, or reach for the Processing menu. If the canvas holds unsaved work or a run is still in flight, Model Builder asks before replacing it. | | **Symbology** | Applies a **graduated** (numeric) or **categorized** (text) color ramp to a layer. | | **Add data** | Adds a layer from a public GeoJSON URL, or an XYZ tile basemap by name (`osm`, `opentopomap`, `carto-dark`) or a custom `{z}/{x}/{y}` URL. | | **Earth observation** | Searches the Microsoft [Planetary Computer](https://planetarycomputer.microsoft.com) STAC catalog (Sentinel-2, Landsat, NAIP, DEMs, …) and adds an item over the current view as a raster layer — tiles are signed server-side, so no credentials are needed. | From ba7bb4d18d5e2e79e4e8f004f3d1e0ce9c0b9638 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 08:13:51 -0400 Subject: [PATCH 05/10] Address Claude review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A `layer` slot the assistant filled in `parameters` instead of wiring to a port was only checked as "some non-empty string". The canvas's own field is a picker that can only hold a real `layer.id`, but the assistant sees free text and may write a layer name — which passes every structural check and then fails at Run time, where `layerToModelValue` looks the value up by exact id. Resolve every unwired layer slot through `resolveLayer` the way `definition.inputs` already is, so the saved graph holds ids only, and throw a named error when nothing matches. - The test fixtures listed a layer only as a port, diverging from `vectorToolDescriptor`, which lists it as both a port and a parameter — so the suite never covered that path. Fixtures now mirror the real shape, plus a case for the name-to-id normalization and the unmatched-name error. --- .../src/lib/assistant/model-builder.ts | 20 +++++++++ tests/assistant-model-builder.test.ts | 42 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts index eb4c0754d1..6ce5d282b0 100644 --- a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts +++ b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts @@ -188,6 +188,26 @@ export function buildAssistantModel( toPort: portId, }); } + // A layer slot no edge supplies names a project layer by hand. The canvas's + // own field is a picker that can only store a real `layer.id`, but the + // assistant sees the same slot as free text and may write a layer name — + // which passes every structural check and then fails at Run time, where + // `layerToModelValue` looks the value up by exact id. Resolve it here the + // way `definition.inputs` is resolved, so the saved graph holds ids only. + const layerSlots = new Set(descriptor.inputs.map((port) => port.id)); + for (const param of descriptor.parameters) { + if (param.type === "layer") layerSlots.add(param.id); + } + for (const slot of layerSlots) { + if (wired.has(slot)) continue; + const raw = parameters[slot]; + if (typeof raw !== "string" || !raw) continue; + const layer = resolveLayer(raw); + if (!layer) { + throw new Error(`No layer matching "${raw}" for "${slot}" of "${step.algorithm}".`); + } + parameters[slot] = layer.id; + } checkStepParameters(step, descriptor, parameters, wired); nodes.push({ id, diff --git a/tests/assistant-model-builder.test.ts b/tests/assistant-model-builder.test.ts index c1306fe617..20b33fd796 100644 --- a/tests/assistant-model-builder.test.ts +++ b/tests/assistant-model-builder.test.ts @@ -12,7 +12,12 @@ const BUFFER: ModelToolDescriptor = { group: "Geometry", inputs: [{ id: "layer", label: "Input", kind: "vector", required: true }], outputs: [{ id: "out", label: "Output", kind: "vector" }], - parameters: [{ id: "distance", label: "Distance", type: "number", required: true }], + // `vectorToolDescriptor` lists a layer parameter as both a port and a + // parameter, so a single-node model can name a layer without an input node. + parameters: [ + { id: "layer", label: "Input", type: "layer", required: true }, + { id: "distance", label: "Distance", type: "number", required: true }, + ], }; const CLIP: ModelToolDescriptor = { @@ -26,7 +31,10 @@ const CLIP: ModelToolDescriptor = { { id: "overlay", label: "Overlay", kind: "vector", required: true }, ], outputs: [{ id: "out", label: "Output", kind: "vector" }], - parameters: [], + parameters: [ + { id: "layer", label: "Input", type: "layer", required: true }, + { id: "overlay", label: "Overlay", type: "layer", required: true }, + ], }; /** A same-id tool from the other registry, the collision `modelToolKey` guards. */ @@ -180,6 +188,36 @@ describe("AI-created Model Builder models", () => { assert.equal(tool?.provider, "vector"); }); + it("resolves a layer named in parameters rather than wired to a port", () => { + const base = { + name: "Named layer", + inputs: [], + steps: [ + { + key: "result", + algorithm: "buffer", + inputs: {}, + parameters: { layer: "Roads", distance: 100 } as Record, + }, + ], + outputs: [{ source: "result", name: "Result" }], + }; + const model = buildAssistantModel(base, layers, [BUFFER], ids()); + const tool = model.graph?.nodes.find((node) => node.kind === "tool"); + // Run time looks the value up by exact id, so the name must not survive. + assert.equal(tool?.parameters?.layer, "roads-id"); + assert.throws( + () => + buildAssistantModel( + { ...base, steps: [{ ...base.steps[0], parameters: { layer: "Rivers", distance: 1 } }] }, + layers, + [BUFFER], + ids(), + ), + /No layer matching "Rivers"/, + ); + }); + it("checks step parameters against the tool's own declaration", () => { const base = { name: "Bad parameters", From e8e94bdb7bef6b6b21bac09de7e83b0b28ca93d3 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 08:20:17 -0400 Subject: [PATCH 06/10] Address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `resolveLayer` matched a layer name case-insensitively, so two layers whose names differ only by case resolved to whichever came first. Keep the case-insensitive fallback — the assistant paraphrases casing, and making the match strict only turns a workable reference into a failure — but try the exact name first and reject a reference that matches more than one layer, which is the actual ambiguity. The tool's `layer` description no longer claims the name must be exact. - Tests cover the unique case-insensitive match, exact-case precedence, and the ambiguous rejection. --- .../src/lib/assistant/model-builder.ts | 14 +++++- .../src/lib/assistant/tools.ts | 2 +- tests/assistant-model-builder.test.ts | 43 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts index 6ce5d282b0..bd6c1ec5c0 100644 --- a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts +++ b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts @@ -151,8 +151,18 @@ export function buildAssistantModel( const resolveLayer = (reference: string): GeoLibreLayer | undefined => { const exactId = layers.find((layer) => layer.id === reference); if (exactId) return exactId; - const target = reference.trim().toLowerCase(); - return layers.find((layer) => layer.name.toLowerCase() === target); + const trimmed = reference.trim(); + const exactName = layers.find((layer) => layer.name === trimmed); + if (exactName) return exactName; + // The assistant paraphrases casing, so a name still matches case-insensitively + // — but only while it picks out a single layer. Two layers differing by case + // alone would otherwise silently resolve to whichever came first. + const target = trimmed.toLowerCase(); + const matches = layers.filter((layer) => layer.name.toLowerCase() === target); + if (matches.length > 1) { + throw new Error(`"${reference}" matches more than one layer name; use the layer id.`); + } + return matches[0]; }; definition.inputs.forEach((input, index) => { diff --git a/apps/geolibre-desktop/src/lib/assistant/tools.ts b/apps/geolibre-desktop/src/lib/assistant/tools.ts index d3c8f6579c..8969dd7e05 100644 --- a/apps/geolibre-desktop/src/lib/assistant/tools.ts +++ b/apps/geolibre-desktop/src/lib/assistant/tools.ts @@ -744,7 +744,7 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool { ); }); + it("prefers an exact layer name and rejects an ambiguous one", () => { + const base = { + name: "Case", + inputs: [{ key: "roads", layer: "roads" }], + steps: [ + { + key: "result", + algorithm: "buffer", + inputs: { layer: "roads" }, + parameters: { distance: 100 }, + }, + ], + outputs: [{ source: "result", name: "Result" }], + }; + // A single case-insensitive match still resolves: the assistant paraphrases + // casing, and there is nothing else the reference could mean. + const model = buildAssistantModel(base, layers, [BUFFER], ids()); + const input = model.graph?.nodes.find((node) => node.kind === "input"); + assert.equal(input?.layerId, "roads-id"); + + const cased = [...layers, { id: "roads-lower", name: "roads" } as GeoLibreLayer]; + // The exact-case name wins over the case-insensitive one. + assert.equal( + buildAssistantModel( + { ...base, inputs: [{ key: "roads", layer: "Roads" }] }, + cased, + [BUFFER], + ids(), + ).graph?.nodes.find((node) => node.kind === "input")?.layerId, + "roads-id", + ); + assert.throws( + () => + buildAssistantModel( + { ...base, inputs: [{ key: "roads", layer: "ROADS" }] }, + cased, + [BUFFER], + ids(), + ), + /matches more than one layer name/, + ); + }); + it("checks step parameters against the tool's own declaration", () => { const base = { name: "Bad parameters", From ccbf8c4ffecdeefb1086dc3013806982c31dfb57 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 08:34:21 -0400 Subject: [PATCH 07/10] feat(assistant): give Model Builder the full tool catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assistant's model tools only ever loaded VECTOR_TOOLS, so asking it to "create a model that extracts a stream network from the DEM" produced a correct-but-useless refusal: no hydrology algorithm existed to find. The Model Builder canvas itself has always offered the Whitebox catalog, so the gap was in this flow alone. - `loadModelToolDescriptors` now builds the same palette the canvas does — VECTOR_TOOLS plus the Whitebox catalog snapshot merged with the WASM manifests — with the two remote sources degrading independently via `Promise.allSettled`, matching ModelBuilderPanel. - That is ~1000 tools, far too many to serialize into one tool result, so `list_model_algorithms` takes a `search` filter (name, id or group, through the existing `searchModelTools`) and returns at most 25 full descriptors. Unfiltered it returns the vector tools in full plus the Whitebox group names to search within, so discovery still works in two calls. - Algorithm ids are reported qualified (`vector:buffer`, `whitebox:fill_depressions`), which is what `resolveDescriptor` prefers and sidesteps the cross-registry id collision entirely. - The tool description and the user guide say raster chains are in scope again, replacing the wording that documented the hole. Verified live against the app with a real model (gpt-5.6-luna): the prompt that previously failed now emits `create_model_builder_model` with a five-step chain — fill_depressions -> d8_pointer -> qin_flow_accumulation -> extract_streams -> raster_streams_to_vector — and the canvas opens with all five nodes wired, the DEM as input, and Run enabled (the graph validates). --- .../src/lib/assistant/tools.ts | 115 ++++++++++++++---- docs/user-guide/ai-assistant.md | 2 +- 2 files changed, 93 insertions(+), 24 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/assistant/tools.ts b/apps/geolibre-desktop/src/lib/assistant/tools.ts index 8969dd7e05..11db34c970 100644 --- a/apps/geolibre-desktop/src/lib/assistant/tools.ts +++ b/apps/geolibre-desktop/src/lib/assistant/tools.ts @@ -51,21 +51,66 @@ interface LayerSummary { } /** - * The algorithms the assistant may place in a Model Builder graph. + * The algorithms the assistant may place in a Model Builder graph: the same + * palette the canvas itself offers, so anything a user could drag in, the + * assistant can wire up — the client vector registry plus the Whitebox catalog + * snapshot and the WASM manifests. * * `list_model_algorithms` and `create_model_builder_model` both read this one * list, so the ids offered to the model and the ids `buildAssistantModel` * resolves cannot drift apart. Imported dynamically to keep the processing - * registry out of the assistant's initial chunk. + * registry out of the assistant's initial chunk. The two remote sources degrade + * independently, matching `ModelBuilderPanel`: losing one still leaves a usable + * palette built from the other, rather than failing the whole tool call. */ async function loadModelToolDescriptors(): Promise { - const [{ VECTOR_TOOLS }, { vectorToolDescriptor }] = await Promise.all([ - import("@geolibre/processing"), - import("../model-tool-catalog"), + const [ + { + VECTOR_TOOLS, + fetchRemoteWhiteboxCatalogSnapshot, + listWasmToolManifests, + mergeWasmToolManifests, + }, + { buildModelToolCatalog }, + ] = await Promise.all([import("@geolibre/processing"), import("../model-tool-catalog")]); + const [catalogResult, wasmResult] = await Promise.allSettled([ + fetchRemoteWhiteboxCatalogSnapshot(), + listWasmToolManifests(), ]); - return VECTOR_TOOLS.map(vectorToolDescriptor); + if (catalogResult.status === "rejected") { + console.warn("[GeoLibre] Assistant could not load the Whitebox catalog:", catalogResult.reason); + } + if (wasmResult.status === "rejected") { + console.warn("[GeoLibre] Assistant could not enumerate WASM manifests:", wasmResult.reason); + } + return buildModelToolCatalog( + VECTOR_TOOLS, + mergeWasmToolManifests( + catalogResult.status === "fulfilled" ? catalogResult.value : [], + wasmResult.status === "fulfilled" ? wasmResult.value : [], + ), + ); +} + +/** The model-facing shape of one algorithm: ports and parameters, no manifest. */ +function modelAlgorithmDetail(descriptor: ModelToolDescriptor) { + return { + // Qualified, because two registries can define the same bare id and + // `buildAssistantModel` rejects a colliding one until it is namespaced. + id: descriptor.key, + provider: descriptor.provider, + name: descriptor.name, + group: descriptor.group, + description: descriptor.description, + inputs: descriptor.inputs, + parameters: descriptor.parameters, + outputs: descriptor.outputs, + }; } +/** Full detail for at most this many `list_model_algorithms` search hits. */ +const MAX_MODEL_ALGORITHM_MATCHES = 25; + /** Statement keywords that write data or have side effects. */ const SQL_WRITE_KEYWORDS = /\b(INSERT|UPDATE|DELETE|MERGE|CREATE|DROP|ALTER|TRUNCATE|REPLACE|ATTACH|DETACH|COPY|EXPORT|IMPORT|INSTALL|LOAD|PRAGMA|VACUUM|CHECKPOINT)\b/; @@ -715,22 +760,44 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool { + "List algorithms that can be placed in Model Builder — client-side vector tools plus the full Whitebox/raster catalog (hydrology, terrain, LiDAR, image processing) — with their exact input-port and parameter ids. The catalog runs to ~1000 tools, so pass `search` to filter by name, id or group ('stream', 'flow accumulation', 'hydro', 'terrain'); without it you get the vector tools in full plus the Whitebox group names to search within. Call this before create_model_builder_model and use the exact ids it returns.", + inputSchema: z.object({ + search: z + .string() + .optional() + .describe("Filter by tool name, id or group, e.g. 'stream' or 'hydrology'."), + }), + callback: async (input) => { + const [catalog, { searchModelTools }] = await Promise.all([ + loadModelToolDescriptors(), + import("../model-tool-catalog"), + ]); + const query = input.search?.trim(); + if (query) { + const matches = searchModelTools(catalog, query); + return json({ + search: query, + matched: matches.length, + // Enough for the model to pick from without flooding the context; a + // narrower search is the way to see the rest. + truncated: matches.length > MAX_MODEL_ALGORITHM_MATCHES, + algorithms: matches.slice(0, MAX_MODEL_ALGORITHM_MATCHES).map(modelAlgorithmDetail), + }); + } + // Unfiltered, the Whitebox half is far too large to serialize, so it is + // summarized to its groups. Searching one of those group names returns + // the tools inside it with full ports and parameters. + const groups = new Map(); + for (const descriptor of catalog) { + if (descriptor.provider === "vector") continue; + groups.set(descriptor.group, (groups.get(descriptor.group) ?? 0) + 1); + } return json({ - algorithms: (await loadModelToolDescriptors()).map((descriptor) => ({ - id: descriptor.toolId, - // The registry a tool came from is part of its identity: two - // registries can define the same id, so a step naming a colliding - // bare id is rejected until it is qualified as `provider:id`. - provider: descriptor.provider, - name: descriptor.name, - description: descriptor.description, - inputs: descriptor.inputs, - parameters: descriptor.parameters, - outputs: descriptor.outputs, - })), + algorithms: catalog.filter((d) => d.provider === "vector").map(modelAlgorithmDetail), + rasterGroups: [...groups] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([group, tools]) => ({ group, tools })), + hint: "Raster/Whitebox tools are summarized by group. Call again with `search` (a group name, a tool name, or a keyword like 'stream') to get their exact ids, ports and parameters.", }); }, }); @@ -738,7 +805,7 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool Date: Thu, 20 Aug 2026 08:49:06 -0400 Subject: [PATCH 08/10] feat(assistant): let the assistant run Whitebox raster tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_algorithm only ever resolved against the client vector registries, so a raster request ("compute slope from this DEM") had no tool to reach for and the assistant correctly reported there was none. The scripting layer already had working `listWhiteboxTools`/`runWhiteboxTool` handlers driving the WASM runner — they were simply never exposed to the assistant. - New `list_whitebox_tools` / `run_whitebox_tool` tools over those handlers. As with list_model_algorithms, the catalog is ~1000 tools, so the list tool takes a `search` filter and caps detail at 25 hits; unfiltered it returns the categories to search within. - list_algorithms' description and the system prompt now send raster work to the Whitebox pair instead of leaving the model to conclude nothing exists. - A GeoTIFF returned under a generic `file_out` parameter now becomes a raster layer instead of being reported as unretrievable. Several raster tools declare their output that way — `slope`'s is described only as "Optional output path" — and ProcessingDialog can treat those as files because it hands the user a download; this API has no such affordance, so the raster the caller asked for silently vanished. `isTiff` lives in a new leaf module (`scripting/binary-output.ts`) so it is testable without dragging the app's maplibre CSS imports into the node test runner. Verified live with a real model (gpt-5.6-luna): "compute a slope raster from the dem" now emits list_whitebox_tools{search:"slope"} then run_whitebox_tool {id:"slope", input:, units:"degrees"}, and "Slope output" lands in the Layers panel as a COG rendering over the DEM. Before this change the same prompt ran the tool and then dropped the result. --- .../src/lib/assistant/agent.ts | 3 +- .../src/lib/assistant/tools.ts | 81 ++++++++++++++++++- .../src/lib/scripting/binary-output.ts | 24 ++++++ .../src/lib/scripting/scriptingApi.ts | 3 +- docs/user-guide/ai-assistant.md | 1 + tests/whitebox-output-tiff.test.ts | 27 +++++++ 6 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 apps/geolibre-desktop/src/lib/scripting/binary-output.ts create mode 100644 tests/whitebox-output-tiff.test.ts diff --git a/apps/geolibre-desktop/src/lib/assistant/agent.ts b/apps/geolibre-desktop/src/lib/assistant/agent.ts index 87d9605909..a0c502daa5 100644 --- a/apps/geolibre-desktop/src/lib/assistant/agent.ts +++ b/apps/geolibre-desktop/src/lib/assistant/agent.ts @@ -18,7 +18,8 @@ Guidelines: - Call list_layers to discover the current layers, their attribute fields, and the SQL table names before referencing them. - For data questions, prefer run_sql with a single read-only DuckDB Spatial SQL statement against the SQL table names from list_layers. Show the SQL you ran. Only add the result as a layer when the user asks to map it or when geometry is clearly wanted. - For styling requests, use apply_symbology with the layer's real field names. -- For geoprocessing (buffer, clip, dissolve, intersection, difference, union, spatial join, simplify, centroids, H3 grids, …), call list_algorithms to discover ids and typed parameters, then run_algorithm with the algorithm id and parameters. A 'layer' parameter takes a layer id. Build a multi-step pipeline by feeding one run's returned result layer id into the next. +- For vector geoprocessing (buffer, clip, dissolve, intersection, difference, union, spatial join, simplify, centroids, H3 grids, …), call list_algorithms to discover ids and typed parameters, then run_algorithm with the algorithm id and parameters. A 'layer' parameter takes a layer id. Build a multi-step pipeline by feeding one run's returned result layer id into the next. +- For raster work (hydrology, terrain, LiDAR, image processing, raster↔vector conversion), the vector algorithms do not apply: call list_whitebox_tools with a \`search\` keyword to find the tool and its exact parameter names, then run_whitebox_tool. A raster/vector input parameter takes a layer id. Never tell the user a raster operation is unavailable without searching this catalog first. - When the user asks to create, design, or build a reusable Model Builder model, do not execute the pipeline immediately. Call list_model_algorithms, then create_model_builder_model to save a validated editable graph and open it for review. - To add satellite/aerial imagery or other earth-observation data, use search_stac and add_stac_layer against the Planetary Computer (collections such as sentinel-2-l2a, landsat-c2-l2, naip, cop-dem-glo-30); the bounding box defaults to the current view. - To add tile basemaps (OpenStreetMap, OpenTopoMap, CARTO Dark Matter, etc.), use add_tile_layer with a known name or an XYZ url, rather than asking the user or saying you cannot. diff --git a/apps/geolibre-desktop/src/lib/assistant/tools.ts b/apps/geolibre-desktop/src/lib/assistant/tools.ts index 11db34c970..22de711e03 100644 --- a/apps/geolibre-desktop/src/lib/assistant/tools.ts +++ b/apps/geolibre-desktop/src/lib/assistant/tools.ts @@ -111,6 +111,9 @@ function modelAlgorithmDetail(descriptor: ModelToolDescriptor) { /** Full detail for at most this many `list_model_algorithms` search hits. */ const MAX_MODEL_ALGORITHM_MATCHES = 25; +/** Full detail for at most this many `list_whitebox_tools` search hits. */ +const MAX_WHITEBOX_MATCHES = 25; + /** Statement keywords that write data or have side effects. */ const SQL_WRITE_KEYWORDS = /\b(INSERT|UPDATE|DELETE|MERGE|CREATE|DROP|ALTER|TRUNCATE|REPLACE|ATTACH|DETACH|COPY|EXPORT|IMPORT|INSTALL|LOAD|PRAGMA|VACUUM|CHECKPOINT)\b/; @@ -342,12 +345,24 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool unknown; runAlgorithm: (input: { id: string; params: Record; }) => Promise<{ logs?: string[]; resultLayerIds?: string[] }>; + listWhiteboxTools: () => Promise; + runWhiteboxTool: (input: { + id: string; + params: Record; + }) => Promise<{ logs?: string[]; resultLayerIds?: string[] }>; }; let scriptingPromise: Promise | null = null; const getScripting = (): Promise => { @@ -727,7 +742,7 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool json({ algorithms: (await getScripting()).listAlgorithms() }), }); @@ -757,6 +772,68 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool { + const tools = await (await getScripting()).listWhiteboxTools(); + const query = input.search?.trim().toLowerCase(); + if (query) { + const matches = tools.filter((item) => + `${item.name} ${item.id} ${item.category}`.toLowerCase().includes(query), + ); + return json({ + search: input.search, + matched: matches.length, + truncated: matches.length > MAX_WHITEBOX_MATCHES, + tools: matches.slice(0, MAX_WHITEBOX_MATCHES), + }); + } + // ~1000 tools with full parameter lists is far too much to serialize, so + // an unfiltered call returns the categories to search within instead. + const categories = new Map(); + for (const item of tools) { + categories.set(item.category, (categories.get(item.category) ?? 0) + 1); + } + return json({ + total: tools.length, + categories: [...categories] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([category, count]) => ({ category, tools: count })), + hint: "Call again with `search` (a category, a tool name, or a keyword like 'stream') to get exact ids and parameters.", + }); + }, + }); + + const runWhiteboxTool = tool({ + name: "run_whitebox_tool", + description: + "Run a Whitebox tool by id (from list_whitebox_tools) in the browser via WASM and add its results as new layers. `params` is keyed by the tool's exact parameter names; a raster/vector input parameter takes a layer id (from list_layers). Chain steps by feeding one run's returned result layer id into the next. Returns the run log and the new layer id(s).", + inputSchema: z.object({ + id: z.string().describe("Whitebox tool id, e.g. 'fill_depressions', 'slope'."), + params: z + .record(z.string(), z.unknown()) + .optional() + .describe("Parameter values keyed by parameter name; input parameters take a layer id."), + }), + callback: async (input) => { + const result = await ( + await getScripting() + ).runWhiteboxTool({ id: input.id, params: input.params ?? {} }); + return json({ + logs: result.logs ?? [], + resultLayerIds: result.resultLayerIds ?? [], + }); + }, + }); + const listModelAlgorithms = tool({ name: "list_model_algorithms", description: @@ -962,6 +1039,8 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool sig.every((b, i) => bytes[i] === b); + // "II" little-endian / "MM" big-endian, then version 42 (TIFF) or 43 (BigTIFF). + return ( + matches([0x49, 0x49, 0x2a, 0x00]) || + matches([0x4d, 0x4d, 0x00, 0x2a]) || + matches([0x49, 0x49, 0x2b, 0x00]) || + matches([0x4d, 0x4d, 0x00, 0x2b]) + ); +} diff --git a/apps/geolibre-desktop/src/lib/scripting/scriptingApi.ts b/apps/geolibre-desktop/src/lib/scripting/scriptingApi.ts index 1145fc106d..d062b0ed02 100644 --- a/apps/geolibre-desktop/src/lib/scripting/scriptingApi.ts +++ b/apps/geolibre-desktop/src/lib/scripting/scriptingApi.ts @@ -17,6 +17,7 @@ import { SKETCHES_SOURCE_KIND, addRasterToMap } from "@geolibre/plugins"; import type { Feature, FeatureCollection } from "geojson"; import type { RefObject } from "react"; import type { MapController } from "@geolibre/map"; +import { isTiff } from "./binary-output"; import { beginProcessingRun } from "../processing-history"; import { captureMapImage } from "../print-layout-export"; import { styleParamPatch } from "./style-params"; @@ -410,7 +411,7 @@ export function createScriptingHandlers(deps: ScriptingDeps): ScriptingHandlers // GeoLibre-authored subset extractors, whose produced COG comes back // under a key with no typed param at all (SUBSET_OUTPUT_TOOL_IDS in // wasm-client), so parameterKind falls through to "string". - if (outKind === "file_out" || outKind === "vector_out") { + if ((outKind === "file_out" || outKind === "vector_out") && !isTiff(value)) { unretrievable.push( `Output "${outputName}" (${outKind}, ${value.length} bytes) is a file, not a map layer; run this tool from Processing to download it.`, ); diff --git a/docs/user-guide/ai-assistant.md b/docs/user-guide/ai-assistant.md index 405c8ad732..0f7c5ca4cf 100644 --- a/docs/user-guide/ai-assistant.md +++ b/docs/user-guide/ai-assistant.md @@ -165,6 +165,7 @@ operations, so its actions stay within GeoLibre's validated surface. | **Inspect layers** | Lists loaded layers, their geometry, attribute fields, and SQL table names (schema only — never your full data). | | **NL → Spatial SQL** | Generates and runs a **read-only** DuckDB Spatial SQL query through the [SQL Workspace](sql-workspace.md), and can add the result as a layer. | | **Geoprocessing** | Runs the registered [processing](processing.md) algorithms (buffer, clip, dissolve, intersection, difference, union, spatial join, simplify, H3 grids, …) and chains them into multi-step pipelines, adding each result as a layer. | +| **Raster tools** | Searches the Whitebox catalog and runs its tools in the browser via WASM — hydrology (fill depressions, flow accumulation, extract streams), terrain (slope, aspect, hillshade), LiDAR, image processing, raster↔vector conversion — adding each result as a layer. | | **Model Builder** | Creates a validated, editable Model Builder workflow from a description, saves it with the project, and opens it for review before you run it. It draws on the same palette as the canvas — client-side vector tools plus the full Whitebox catalog — so a raster chain such as fill depressions → flow accumulation → extract streams is a valid model. If the canvas holds unsaved work or a run is still in flight, Model Builder asks before replacing it. | | **Symbology** | Applies a **graduated** (numeric) or **categorized** (text) color ramp to a layer. | | **Add data** | Adds a layer from a public GeoJSON URL, or an XYZ tile basemap by name (`osm`, `opentopomap`, `carto-dark`) or a custom `{z}/{x}/{y}` URL. | diff --git a/tests/whitebox-output-tiff.test.ts b/tests/whitebox-output-tiff.test.ts new file mode 100644 index 0000000000..a50bdb3a6e --- /dev/null +++ b/tests/whitebox-output-tiff.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { isTiff } from "../apps/geolibre-desktop/src/lib/scripting/binary-output"; + +/** A header only: the sniff never reads past the first four bytes. */ +function header(...bytes: number[]): Uint8Array { + return new Uint8Array([...bytes, 0x00, 0x00, 0x00, 0x00]); +} + +describe("Whitebox binary output sniffing", () => { + it("recognizes every TIFF flavour a raster tool can emit", () => { + // Several raster tools (slope, aspect, hillshade) declare a generic + // `file_out` yet write a GeoTIFF; without this they never reach the map. + assert.equal(isTiff(header(0x49, 0x49, 0x2a, 0x00)), true, "little-endian TIFF"); + assert.equal(isTiff(header(0x4d, 0x4d, 0x00, 0x2a)), true, "big-endian TIFF"); + assert.equal(isTiff(header(0x49, 0x49, 0x2b, 0x00)), true, "little-endian BigTIFF"); + assert.equal(isTiff(header(0x4d, 0x4d, 0x00, 0x2b)), true, "big-endian BigTIFF"); + }); + + it("leaves the genuinely file-shaped outputs alone", () => { + assert.equal(isTiff(header(0x50, 0x41, 0x52, 0x31)), false, "GeoParquet"); + assert.equal(isTiff(header(0x66, 0x67, 0x62, 0x03)), false, "FlatGeobuf"); + assert.equal(isTiff(header(0x50, 0x4b, 0x03, 0x04)), false, "zipped Shapefile"); + assert.equal(isTiff(header(0x89, 0x50, 0x4e, 0x47)), false, "PNG"); + assert.equal(isTiff(new Uint8Array()), false, "empty output"); + }); +}); From d8bc6686069b48a8444ffcd9906b72e77dccf034 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 09:09:02 -0400 Subject: [PATCH 09/10] feat(model-builder): set input and output nodes apart, arrange on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Input and output cards are rounded and tinted, so the data entering and leaving a model reads apart from the tools between it — the split ArcGIS ModelBuilder and QGIS draw as ovals versus rectangles. Safe for exactly these two kinds: each has a single, vertically centred port, so no port dot lands on the rounded part of the edge. - Those two kinds sized to the bare NODE_HEIGHT while every labelled tool card is 70px, leaving them visibly short beside their neighbours. They now take a one-row labelled tool's height, and the unlabelled port band spans the card rather than a hardcoded NODE_HEIGHT so the single port stays centred. - A model opened from the AI Assistant is auto-arranged. handleLoadModel does lay it out, but this request also opens the panel, so on that pass the canvas has no measured width yet and the layout fell back to its default — leaving a long assistant-built chain off the visible area until the user pressed Arrange. Re-arrange on the next frame, once the canvas is measured, which also scrolls it to the start. handleLoadModel now reports whether it loaded, so a declined discard does not rearrange what the user kept. - The assistant uses fill_depressions_wang_and_liu rather than the plain fill_depressions tool when a workflow needs depression filling. Verified live with a real model (gpt-5.6-luna): "create a model … that can extract stream network as vector for the dem" produces fill_depressions_wang_and_liu -> d8_pointer -> qin_flow_accumulation -> extract_streams -> raster_streams_to_vector, and the canvas opens already tidied at the origin with the input and output cards rounded, tinted and 70px like the tools between them. --- .../model-builder/ModelBuilderPanel.tsx | 54 +++++++++++++------ .../src/lib/assistant/agent.ts | 2 +- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index fa6783d9e0..2b3253f2f2 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -193,10 +193,13 @@ function cardLayout( const rows = Math.max(ports.inputs.length, ports.outputs.length); // A labelled card is always a little taller than a bare one: at the compact // height a single row's text sits hard against the title above and the card - // edge below. + // edge below. The synthetic input/output cards carry no port label, but + // sizing them bare would leave them visibly shorter than every tool card + // beside them, so they take the height of a one-row labelled tool and a + // chain lines up. const height = - labelIn || labelOut - ? Math.max(NODE_HEIGHT + 6, CARD_HEADER_HEIGHT + rows * PORT_ROW_HEIGHT + 8) + labelIn || labelOut || kind !== "tool" + ? Math.max(NODE_HEIGHT + 6, CARD_HEADER_HEIGHT + Math.max(rows, 1) * PORT_ROW_HEIGHT + 8) : NODE_HEIGHT; // Both sides share one vertical band. A labelled side fills it row by row; an // unlabelled side spreads its dots down the same band rather than down the @@ -205,7 +208,7 @@ function cardLayout( const band: PortBand = labelIn || labelOut ? { top: CARD_HEADER_HEIGHT, height: rows * PORT_ROW_HEIGHT } - : { top: 0, height: NODE_HEIGHT }; + : { top: 0, height }; return { height, labelIn, labelOut, band }; } @@ -498,18 +501,28 @@ export function ModelBuilderPanel({ resetRunState(); }, [confirmDiscard, resetRunState]); + /** @returns False when the user declined to discard what was on the canvas. */ const handleLoadModel = useCallback( - (model: ProcessingModel) => { - if (!confirmDiscard()) return; + (model: ProcessingModel): boolean => { + if (!confirmDiscard()) return false; setModelId(model.id); setModelName(model.name); setGraph(autoLayout(model.graph ?? stepsToGraph(model), layoutOptions())); setSelectedNodeId(null); resetRunState(); + return true; }, [confirmDiscard, layoutOptions, resetRunState], ); + /** Re-run the depth-based layout over the nodes the user has moved around. */ + const handleArrange = useCallback(() => { + setGraph((current) => layoutGraph(current, layoutOptions())); + // The layout starts at the origin, so a canvas left scrolled somewhere + // else would open on empty space right after tidying it up. + canvasRef.current?.scrollTo({ left: 0, top: 0 }); + }, [layoutOptions]); + // Programmatic entry points (notably the AI Assistant) save a normal project // model and request that it be shown. Reuse the regular load path so an // unsaved canvas still receives its discard confirmation. @@ -517,8 +530,16 @@ export function ModelBuilderPanel({ if (!open || !requestedModelId) return; const requested = savedModels.find((model) => model.id === requestedModelId); setRequestedModelId(null); - if (requested) handleLoadModel(requested); - }, [open, requestedModelId, savedModels, setRequestedModelId, handleLoadModel]); + if (!requested || !handleLoadModel(requested)) return; + // This request usually opens the panel, so on this pass the canvas is + // still mounting and the `layoutOptions()` inside handleLoadModel measures + // a width of zero — the layout then falls back to its default and a long + // assistant-built chain lands off the visible area. Re-arrange on the next + // frame, once the canvas has real width, so the model appears tidied and + // scrolled to its start rather than needing a manual Arrange. + const frame = requestAnimationFrame(() => handleArrange()); + return () => cancelAnimationFrame(frame); + }, [open, requestedModelId, savedModels, setRequestedModelId, handleLoadModel, handleArrange]); /** * Forget the loaded model. The picker only offers models the project already @@ -544,14 +565,6 @@ export function ModelBuilderPanel({ }); }, []); - /** Re-run the depth-based layout over the nodes the user has moved around. */ - const handleArrange = useCallback(() => { - setGraph((current) => layoutGraph(current, layoutOptions())); - // The layout starts at the origin, so a canvas left scrolled somewhere - // else would open on empty space right after tidying it up. - canvasRef.current?.scrollTo({ left: 0, top: 0 }); - }, [layoutOptions]); - const handleSave = useCallback(() => { // Also write the legacy linear projection when the graph happens to be a // single vector chain, so a build without the canvas can still run it. @@ -1917,7 +1930,14 @@ const GraphNodeCard = memo(function GraphNodeCard({ }} style={{ left: node.x, top: node.y, width: NODE_WIDTH, height: layout.height }} className={cn( - "absolute cursor-grab select-none rounded-md border bg-card p-2 shadow-sm active:cursor-grabbing", + "absolute cursor-grab select-none border p-2 shadow-sm active:cursor-grabbing", + // A tool is a square-cornered card; the model's own inputs and outputs + // are rounded and tinted, so the data flowing in and out reads apart + // from the processing between it at a glance — the same split ArcGIS + // ModelBuilder and QGIS draw as ovals versus rectangles. Safe for + // exactly these two kinds: each has a single, vertically centred port, + // so no port dot ever lands on the rounded part of the edge. + node.kind === "tool" ? "rounded-md bg-card" : "rounded-xl border-primary/40 bg-primary/10", selected && "border-primary ring-2 ring-primary/30", hasIssue && !selected && "border-destructive", status === "running" && "ring-2 ring-primary", diff --git a/apps/geolibre-desktop/src/lib/assistant/agent.ts b/apps/geolibre-desktop/src/lib/assistant/agent.ts index a0c502daa5..508d239bdd 100644 --- a/apps/geolibre-desktop/src/lib/assistant/agent.ts +++ b/apps/geolibre-desktop/src/lib/assistant/agent.ts @@ -19,7 +19,7 @@ Guidelines: - For data questions, prefer run_sql with a single read-only DuckDB Spatial SQL statement against the SQL table names from list_layers. Show the SQL you ran. Only add the result as a layer when the user asks to map it or when geometry is clearly wanted. - For styling requests, use apply_symbology with the layer's real field names. - For vector geoprocessing (buffer, clip, dissolve, intersection, difference, union, spatial join, simplify, centroids, H3 grids, …), call list_algorithms to discover ids and typed parameters, then run_algorithm with the algorithm id and parameters. A 'layer' parameter takes a layer id. Build a multi-step pipeline by feeding one run's returned result layer id into the next. -- For raster work (hydrology, terrain, LiDAR, image processing, raster↔vector conversion), the vector algorithms do not apply: call list_whitebox_tools with a \`search\` keyword to find the tool and its exact parameter names, then run_whitebox_tool. A raster/vector input parameter takes a layer id. Never tell the user a raster operation is unavailable without searching this catalog first. +- For raster work (hydrology, terrain, LiDAR, image processing, raster↔vector conversion), the vector algorithms do not apply: call list_whitebox_tools with a \`search\` keyword to find the tool and its exact parameter names, then run_whitebox_tool. A raster/vector input parameter takes a layer id. Never tell the user a raster operation is unavailable without searching this catalog first. When a workflow needs depression filling, use fill_depressions_wang_and_liu rather than the plain fill_depressions tool. - When the user asks to create, design, or build a reusable Model Builder model, do not execute the pipeline immediately. Call list_model_algorithms, then create_model_builder_model to save a validated editable graph and open it for review. - To add satellite/aerial imagery or other earth-observation data, use search_stac and add_stac_layer against the Planetary Computer (collections such as sentinel-2-l2a, landsat-c2-l2, naip, cop-dem-glo-30); the bounding box defaults to the current view. - To add tile basemaps (OpenStreetMap, OpenTopoMap, CARTO Dark Matter, etc.), use add_tile_layer with a known name or an XYZ url, rather than asking the user or saying you cannot. From 739becbdadfc1a48db639fbd7fdd15405719a272 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 09:26:25 -0400 Subject: [PATCH 10/10] Address Claude review feedback - `isParameterVisible` now falls back to a governing parameter's declared default, so omitting `aggregate`'s defaulted `statistic` no longer makes the hidden `stat_field` read as a missing required parameter. - A step whose tool has several output ports must name the one it is wired through as `key.port`; a bare reference is refused instead of silently taking the first output. The tool schema documents the syntax. - `buildAssistantModel`'s default id generator guards `crypto.randomUUID` the way `createId` in ModelBuilderPanel does, so graph construction fails as a tool error rather than crashing where that API is absent. - Validation failures report `issue.message` rather than the bare code, giving the assistant something to act on when a `create_model_builder_model` call is rejected. - `tools.ts` imports `buildAssistantModel` dynamically, so `@geolibre/processing` (which `model-builder` pulls in for its graph helpers) stays out of the assistant's initial chunk, matching the comment on `loadModelToolDescriptors`. - The layer list is read after the descriptor catalog resolves, so a layer added or renamed while the Whitebox snapshot loads is not validated against a stale list. - `newProject`/`loadProject` clear `modelBuilderRequestedModelId` alongside the other one-shot UI ids that belong to the previous project. - ProcessingDialog reuses `isTiff` from `binary-output.ts`: a generic `file_out` whose bytes are a GeoTIFF (e.g. `slope`) becomes a raster layer instead of a `.bin` download, matching the scripting/assistant path. --- .../processing/ProcessingDialog.tsx | 15 +- .../src/lib/assistant/model-builder.ts | 82 +++++++++-- .../src/lib/assistant/tools.ts | 30 +++- packages/core/src/store.ts | 6 + tests/assistant-model-builder.test.ts | 132 ++++++++++++++++++ 5 files changed, 244 insertions(+), 21 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/ProcessingDialog.tsx b/apps/geolibre-desktop/src/components/processing/ProcessingDialog.tsx index 14d7ba683c..ed25c628a4 100644 --- a/apps/geolibre-desktop/src/components/processing/ProcessingDialog.tsx +++ b/apps/geolibre-desktop/src/components/processing/ProcessingDialog.tsx @@ -71,6 +71,7 @@ import { type DistanceUnit, } from "../../lib/whitebox-distance-params"; import { parameterKind } from "../../lib/whitebox-param-kind"; +import { isTiff } from "../../lib/scripting/binary-output"; import { canUseLayerForParameter, fetchLayerBytes, @@ -140,12 +141,13 @@ function isOutputParameter(param: WhiteboxToolParameter): boolean { /** * Best-effort extension for a binary tool output, sniffed from its magic bytes. * Covers the formats GeoLibre `file_out` and (CRS-preserving) `vector_out` tools - * emit today (GeoParquet, FlatGeobuf, zipped Shapefile, PNG, PMTiles); a + * emit today (GeoTIFF, GeoParquet, FlatGeobuf, zipped Shapefile, PNG, PMTiles); a * genuinely opaque output falls back to `.bin`. Extend the sniff here if a * future tool writes a recognizable format. */ function fileOutputExtension(bytes: Uint8Array): string { const matches = (sig: number[]) => sig.every((b, i) => bytes[i] === b); + if (isTiff(bytes)) return "tif"; if (matches([0x50, 0x41, 0x52, 0x31])) return "parquet"; // "PAR1" if (matches([0x66, 0x67, 0x62, 0x03])) return "fgb"; // FlatGeobuf "fgb\x03" if (matches([0x50, 0x4b, 0x03, 0x04])) return "zip"; // Shapefile bundle "PK\x03\x04" @@ -1413,13 +1415,18 @@ export function ProcessingDialog({ mapControllerRef, onAddRaster }: ProcessingDi // become a new raster layer; a `file_out` (e.g. write_geoparquet .parquet, // a rendered .png, a .pmtiles) or a CRS-preserving `vector_out` // (GeoParquet/FlatGeobuf/zipped Shapefile, chosen to keep a reprojection's - // target CRS) is not a GeoTIFF, so download it instead of handing it to the - // raster loader. + // target CRS) is downloaded instead — unless its bytes turn out to be a + // GeoTIFF after all, which several tools declare only as a generic file. for (const [name, value] of Object.entries(nextJob.outputs)) { if (!(value instanceof Uint8Array)) continue; const param = jobTool?.params?.find((item) => item.name === name); const outKind = param ? parameterKind(param) : ""; - if (outKind === "file_out" || outKind === "vector_out") { + // A generic `file_out` can still hold a GeoTIFF — `slope` declares its + // output only as "Optional output path" — so sniff the bytes rather + // than trust the declared kind, the way the scripting/assistant path + // does, and put a raster on the map instead of downloading it. + const declaredFile = outKind === "file_out" || outKind === "vector_out"; + if (declaredFile && (!isTiff(value) || !onAddRaster)) { const label = `${jobToolLabel} ${humanize(name)}`.replace(/\s+/g, "_"); // Prefer the content signature: a `vector_out` and most binary // `file_out` formats (GeoParquet/FlatGeobuf/zipped Shapefile/PNG/ diff --git a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts index bd6c1ec5c0..9403feb711 100644 --- a/apps/geolibre-desktop/src/lib/assistant/model-builder.ts +++ b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts @@ -32,11 +32,24 @@ export interface AssistantModelDefinition { outputs: AssistantModelOutput[]; } -/** Whether a parameter applies, given the other values the assistant supplied. */ -function isParameterVisible(param: AlgorithmParameter, values: Record): boolean { +/** + * Whether a parameter applies, given the other values the assistant supplied. + * + * A governing parameter the assistant left out still has an effective value — + * the one the tool declares as its default — so that is what decides + * visibility. Reading `values` alone would make `aggregate`'s `stat_field` look + * visible (and so required) whenever `statistic` is omitted, even though the + * default `"count"` is exactly the case that needs no field. + */ +function isParameterVisible( + param: AlgorithmParameter, + values: Record, + declared: Map, +): boolean { const vw = param.visibleWhen; if (!vw) return true; - const current = values[vw.param] as string | undefined; + const effective = values[vw.param] ?? declared.get(vw.param)?.default; + const current = effective as string | undefined; if ("in" in vw) return current != null && vw.in.includes(current); return current == null || !vw.notIn.includes(current); } @@ -96,7 +109,7 @@ function checkStepParameters( // A wired port carries its value along the edge, and a parameter the tool // defaults needs no explicit value. if (!param.required || wired.has(param.id) || param.default !== undefined) continue; - if (!isParameterVisible(param, values)) continue; + if (!isParameterVisible(param, values, declared)) continue; const value = values[param.id]; if (value === undefined || value === null || value === "") { throw new Error(`Parameter "${param.id}" of "${step.algorithm}" is required.`); @@ -109,7 +122,14 @@ export function buildAssistantModel( definition: AssistantModelDefinition, layers: GeoLibreLayer[], descriptors: ModelToolDescriptor[], - createId: () => string = () => crypto.randomUUID(), + // Guarded the way `createId` in ModelBuilderPanel is: the webview has + // `crypto.randomUUID`, but the embed and non-secure-origin builds need not, + // and there a bare call would crash graph construction instead of failing as + // a tool error the assistant can report. + createId: () => string = () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `id-${Math.random().toString(36).slice(2)}`, ): ProcessingModel { const descriptorByKey = new Map( descriptors.map((descriptor) => [`${descriptor.provider}:${descriptor.toolId}`, descriptor]), @@ -140,7 +160,7 @@ export function buildAssistantModel( if (!bare) throw new Error(`"${algorithm}" is not a Model Builder algorithm.`); return bare; }; - const nodesByKey = new Map(); + const nodesByKey = new Map(); const nodes: ProcessingModelGraph["nodes"] = []; const edges: ProcessingModelGraph["edges"] = []; @@ -148,6 +168,39 @@ export function buildAssistantModel( if (!key.trim()) throw new Error("Every input and step needs a non-empty key."); if (nodesByKey.has(key)) throw new Error(`Duplicate model key "${key}".`); }; + /** + * Resolve a reference to an earlier input or step down to one output port. + * + * Several Whitebox tools write more than one output (magnitude *and* + * direction, say), so a bare key does not say which one an edge should carry. + * Rather than silently taking the first — the kind of quiet truncation + * `graphToLinearSteps` refuses to make — such a reference has to name its + * port as `key.port`. + */ + const resolveSource = ( + reference: string, + ): { id: string; port: string; kind: "input" | "tool" } | undefined => { + const direct = nodesByKey.get(reference); + if (direct) { + if (direct.outputs.length > 1) { + throw new Error( + `"${reference}" has more than one output (${direct.outputs.join(", ")}); reference it as "${reference}.".`, + ); + } + return { id: direct.id, port: direct.outputs[0] ?? "out", kind: direct.kind }; + } + const dot = reference.lastIndexOf("."); + if (dot <= 0) return undefined; + const owner = nodesByKey.get(reference.slice(0, dot)); + if (!owner) return undefined; + const port = reference.slice(dot + 1); + if (!owner.outputs.includes(port)) { + throw new Error( + `"${reference.slice(0, dot)}" has no output port "${port}" (has ${owner.outputs.join(", ")}).`, + ); + } + return { id: owner.id, port, kind: owner.kind }; + }; const resolveLayer = (reference: string): GeoLibreLayer | undefined => { const exactId = layers.find((layer) => layer.id === reference); if (exactId) return exactId; @@ -171,7 +224,7 @@ export function buildAssistantModel( if (!layer) throw new Error(`No layer matching model input "${input.layer}".`); const id = createId(); nodes.push({ id, kind: "input", layerId: layer.id, x: 0, y: index * 112 }); - nodesByKey.set(input.key, { id, outputPort: INPUT_NODE_PORT, kind: "input" }); + nodesByKey.set(input.key, { id, outputs: [INPUT_NODE_PORT], kind: "input" }); }); definition.steps.forEach((step, index) => { @@ -185,7 +238,7 @@ export function buildAssistantModel( if (!inputPorts.has(portId)) { throw new Error(`Algorithm "${step.algorithm}" has no input port "${portId}".`); } - const source = nodesByKey.get(sourceKey); + const source = resolveSource(sourceKey); if (!source) throw new Error(`Model source "${sourceKey}" must be defined before "${step.key}".`); delete parameters[portId]; @@ -193,7 +246,7 @@ export function buildAssistantModel( edges.push({ id: createId(), from: source.id, - fromPort: source.outputPort, + fromPort: source.port, to: id, toPort: portId, }); @@ -230,13 +283,13 @@ export function buildAssistantModel( }); nodesByKey.set(step.key, { id, - outputPort: descriptor.outputs[0]?.id ?? "out", + outputs: descriptor.outputs.length ? descriptor.outputs.map((port) => port.id) : ["out"], kind: "tool", }); }); definition.outputs.forEach((output, index) => { - const source = nodesByKey.get(output.source); + const source = resolveSource(output.source); if (!source) throw new Error(`Unknown model output source "${output.source}".`); if (source.kind !== "tool") throw new Error("A model output must come from an algorithm step."); const id = createId(); @@ -250,7 +303,7 @@ export function buildAssistantModel( edges.push({ id: createId(), from: source.id, - fromPort: source.outputPort, + fromPort: source.port, to: id, toPort: OUTPUT_NODE_PORT, }); @@ -263,7 +316,10 @@ export function buildAssistantModel( provider && toolId ? descriptorByKey.get(`${provider}:${toolId}`) : undefined, ); if (issues.length) { - throw new Error(`Invalid model: ${issues.map((issue) => issue.code).join(", ")}.`); + // `message` rather than `code`: this text is the tool-call result the + // assistant reads, and "missing-input" alone says nothing about which port + // on which node to fix, so a retry would be guesswork. + throw new Error(`Invalid model: ${issues.map((issue) => issue.message).join(" ")}`); } return { id: createId(), diff --git a/apps/geolibre-desktop/src/lib/assistant/tools.ts b/apps/geolibre-desktop/src/lib/assistant/tools.ts index 22de711e03..f7d1011a63 100644 --- a/apps/geolibre-desktop/src/lib/assistant/tools.ts +++ b/apps/geolibre-desktop/src/lib/assistant/tools.ts @@ -18,7 +18,6 @@ import { cleanStatement, maskSqlLiterals, previewLayerTables, runSqlQuery } from import { createXyzTileUrlTemplate } from "../xyz-url"; import { findNamedTileBasemap, NAMED_TILE_BASEMAPS } from "./basemaps"; import { buildSymbologyStyle } from "./symbology"; -import { buildAssistantModel } from "./model-builder"; import { webSearch } from "./web-search"; /** Dependencies the assistant tools need beyond the global store. */ @@ -898,13 +897,36 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool { - const model = buildAssistantModel(input, store().layers, await loadModelToolDescriptors()); + // Loaded here rather than at module scope so `@geolibre/processing` — + // which `model-builder` imports for its graph helpers — stays out of the + // assistant's initial chunk, the same reason `getScripting` defers it. + const [{ buildAssistantModel }, descriptors] = await Promise.all([ + import("./model-builder"), + loadModelToolDescriptors(), + ]); + // Read the layers only once the catalog has loaded: the first call fetches + // the Whitebox snapshot and WASM manifests, and a layer added or renamed + // in that window would otherwise be validated against a stale list. + const model = buildAssistantModel(input, store().layers, descriptors); store().saveModel(model); store().setModelBuilderRequestedModelId(model.id); store().setModelBuilderOpen(true); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 782a3d63b7..6a3655d8fb 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -2242,6 +2242,9 @@ export const useAppStore = create()( selectByLocationLayerId: null, loadEditorFeaturesOpen: false, loadEditorFeaturesLayerId: null, + // A pending assistant-requested Model Builder load names a model in + // the previous project's `savedModels`. + modelBuilderRequestedModelId: null, }, })); clearHistory(); @@ -2302,6 +2305,9 @@ export const useAppStore = create()( selectByLocationLayerId: null, loadEditorFeaturesOpen: false, loadEditorFeaturesLayerId: null, + // A pending assistant-requested Model Builder load names a model in + // the previous project's `savedModels`. + modelBuilderRequestedModelId: null, }, })); clearHistory(); diff --git a/tests/assistant-model-builder.test.ts b/tests/assistant-model-builder.test.ts index ad60b0f290..fadc6186d7 100644 --- a/tests/assistant-model-builder.test.ts +++ b/tests/assistant-model-builder.test.ts @@ -49,6 +49,52 @@ const WHITEBOX_BUFFER: ModelToolDescriptor = { parameters: [], }; +/** A tool with two output ports, as several Whitebox tools have. */ +const CVA: ModelToolDescriptor = { + key: "whitebox:change_vector_analysis", + provider: "whitebox", + toolId: "change_vector_analysis", + name: "Change Vector Analysis", + group: "Whitebox", + inputs: [{ id: "input", label: "Input", kind: "vector", required: true }], + outputs: [ + { id: "magnitude", label: "Magnitude", kind: "vector" }, + { id: "direction", label: "Direction", kind: "vector" }, + ], + parameters: [], +}; + +/** A tool whose required field only applies for some values of its governing select. */ +const AGGREGATE: ModelToolDescriptor = { + key: "vector:aggregate", + provider: "vector", + toolId: "aggregate", + name: "Aggregate", + group: "Analysis", + inputs: [{ id: "layer", label: "Input", kind: "vector", required: true }], + outputs: [{ id: "out", label: "Output", kind: "vector" }], + parameters: [ + { id: "layer", label: "Input", type: "layer", required: true }, + { + id: "statistic", + label: "Statistic", + type: "select", + default: "count", + options: [ + { value: "count", label: "Count" }, + { value: "sum", label: "Sum" }, + ], + }, + { + id: "stat_field", + label: "Field", + type: "field", + required: true, + visibleWhen: { param: "statistic", notIn: ["count"] }, + }, + ], +}; + const layers = [ { id: "roads-id", name: "Roads", type: "geojson" }, { id: "counties-id", name: "Counties", type: "geojson" }, @@ -306,4 +352,90 @@ describe("AI-created Model Builder models", () => { /"distance" of "buffer" is required/, ); }); + + it("takes a governing parameter's declared default into account", () => { + const base = { + name: "Counted", + inputs: [{ key: "roads", layer: "Roads" }], + steps: [{ key: "grouped", algorithm: "aggregate", inputs: { layer: "roads" } }], + outputs: [{ source: "grouped", name: "Counted" }], + }; + // `statistic` defaults to "count", which hides `stat_field` — omitting both + // must not read as a missing required parameter. + const model = buildAssistantModel(base, layers, [AGGREGATE], ids()); + assert.equal(model.graph?.nodes.length, 3); + // Choosing a statistic that does need a field still requires one. + assert.throws( + () => + buildAssistantModel( + { + ...base, + steps: [{ ...base.steps[0], parameters: { statistic: "sum" } }], + }, + layers, + [AGGREGATE], + ids(), + ), + /"stat_field" of "aggregate" is required/, + ); + }); + + it("makes a multi-output step name the port it is wired through", () => { + const base = { + name: "Change", + inputs: [{ key: "roads", layer: "Roads" }], + steps: [{ key: "cva", algorithm: "change_vector_analysis", inputs: { input: "roads" } }], + outputs: [{ source: "cva", name: "Change" }], + }; + assert.throws( + () => buildAssistantModel(base, layers, [CVA], ids()), + /has more than one output \(magnitude, direction\)/, + ); + assert.throws( + () => + buildAssistantModel( + { ...base, outputs: [{ source: "cva.slope", name: "Change" }] }, + layers, + [CVA], + ids(), + ), + /has no output port "slope"/, + ); + const model = buildAssistantModel( + { ...base, outputs: [{ source: "cva.direction", name: "Change" }] }, + layers, + [CVA], + ids(), + ); + const edge = model.graph?.edges.find((item) => item.fromPort === "direction"); + assert.ok(edge, "the output edge carries the named port"); + }); + + it("reports validation issues as readable messages", () => { + assert.throws( + () => + buildAssistantModel( + { + name: "Mismatched", + inputs: [{ key: "dem", layer: "Roads" }], + steps: [ + { key: "raster", algorithm: "whitebox:buffer", inputs: { input: "dem" } }, + { + key: "vector", + algorithm: "vector:buffer", + inputs: { layer: "raster" }, + parameters: { distance: 10 }, + }, + ], + outputs: [{ source: "vector", name: "Buffered" }], + }, + layers, + [BUFFER, WHITEBOX_BUFFER], + ids(), + ), + // The message, not the bare `type-mismatch` code: the assistant reads + // this and needs to know which port is wrong. + /Invalid model: .+/, + ); + }); });