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/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index 339cfefdfb..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 }; } @@ -255,7 +258,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); @@ -464,11 +469,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 @@ -490,18 +501,46 @@ 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. + useEffect(() => { + if (!open || !requestedModelId) return; + const requested = savedModels.find((model) => model.id === requestedModelId); + setRequestedModelId(null); + 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 * holds, so the button is meaningful exactly when the open model is one of @@ -526,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. @@ -1899,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/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/agent.ts b/apps/geolibre-desktop/src/lib/assistant/agent.ts index 7c566f2d1d..508d239bdd 100644 --- a/apps/geolibre-desktop/src/lib/assistant/agent.ts +++ b/apps/geolibre-desktop/src/lib/assistant/agent.ts @@ -18,7 +18,9 @@ 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 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. - 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..9403feb711 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/assistant/model-builder.ts @@ -0,0 +1,330 @@ +import type { GeoLibreLayer, ProcessingModel, ProcessingModelGraph } from "@geolibre/core"; +import { + INPUT_NODE_PORT, + OUTPUT_NODE_PORT, + validateModelGraph, + graphToLinearSteps, + type AlgorithmParameter, + 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[]; +} + +/** + * 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 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); +} + +/** 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, declared)) 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, + layers: GeoLibreLayer[], + descriptors: ModelToolDescriptor[], + // 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]), + ); + /** + * 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"] = []; + + 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}".`); + }; + /** + * 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; + 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) => { + 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, outputs: [INPUT_NODE_PORT], kind: "input" }); + }); + + definition.steps.forEach((step, index) => { + claimKey(step.key); + 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)) { + throw new Error(`Algorithm "${step.algorithm}" has no input port "${portId}".`); + } + const source = resolveSource(sourceKey); + 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, + fromPort: source.port, + to: id, + 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, + kind: "tool", + provider: descriptor.provider, + toolId: descriptor.toolId, + parameters, + x: 260 + index * 260, + y: index * 32, + }); + nodesByKey.set(step.key, { + id, + outputs: descriptor.outputs.length ? descriptor.outputs.map((port) => port.id) : ["out"], + kind: "tool", + }); + }); + + definition.outputs.forEach((output, index) => { + 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(); + 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.port, + 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 issues = validateModelGraph(graph, (provider, toolId) => + provider && toolId ? descriptorByKey.get(`${provider}:${toolId}`) : undefined, + ); + if (issues.length) { + // `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(), + 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..f7d1011a63 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"; @@ -48,6 +49,70 @@ interface LayerSummary { fields: { name: string; type: string }[]; } +/** + * 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. 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, + fetchRemoteWhiteboxCatalogSnapshot, + listWasmToolManifests, + mergeWasmToolManifests, + }, + { buildModelToolCatalog }, + ] = await Promise.all([import("@geolibre/processing"), import("../model-tool-catalog")]); + const [catalogResult, wasmResult] = await Promise.allSettled([ + fetchRemoteWhiteboxCatalogSnapshot(), + listWasmToolManifests(), + ]); + 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; + +/** 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/; @@ -279,12 +344,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 => { @@ -664,7 +741,7 @@ export function createAssistantTools(deps: AssistantToolDeps): InvokableTool json({ algorithms: (await getScripting()).listAlgorithms() }), }); @@ -694,6 +771,180 @@ 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: + "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: 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.", + }); + }, + }); + + const createModelBuilderModel = tool({ + name: "create_model_builder_model", + description: + "Create, save, and open an editable Model Builder workflow. Steps can mix client-side vector tools and Whitebox/raster tools, so a raster chain (e.g. fill depressions → flow accumulation → extract streams → raster-to-vector) is a valid model. 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 the exact ids it returns; the model is always saved, but Model Builder asks before replacing unsaved canvas work or a running job.", + inputSchema: z.object({ + name: z.string(), + inputs: z.array( + z.object({ + key: z.string(), + layer: z.string().describe("Layer id (from list_layers), or the layer's name."), + }), + ), + steps: z.array( + z.object({ + key: z.string(), + algorithm: z + .string() + .describe("Algorithm id from list_model_algorithms, e.g. 'vector:buffer'."), + parameters: z.record(z.string(), z.unknown()).optional(), + inputs: z + .record(z.string(), z.string()) + .describe( + "Maps an input-port id to an earlier input or step key. A step whose tool has several outputs must name the port too, as 'stepKey.portId'.", + ), + }), + ), + outputs: z.array( + z.object({ + source: z + .string() + .describe( + "The step key whose result to add to the map, or 'stepKey.portId' when the tool has several outputs.", + ), + name: z.string(), + }), + ), + }), + callback: async (input) => { + // 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); + return json({ + modelId: model.id, + name: model.name, + nodes: model.graph?.nodes.length ?? 0, + edges: model.graph?.edges.length ?? 0, + saved: true, + // The panel is opened here, but it loads the model from an effect that + // first asks about unsaved canvas work or a run in flight. That answer + // arrives long after this result, so claiming the model is on screen + // would let the assistant report an outcome the user may have declined. + builderOpened: true, + }); + }, + }); + const searchStac = tool({ name: "search_stac", description: @@ -810,6 +1061,10 @@ 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 ffb5ace096..0f7c5ca4cf 100644 --- a/docs/user-guide/ai-assistant.md +++ b/docs/user-guide/ai-assistant.md @@ -165,6 +165,8 @@ 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. | | **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. | @@ -194,6 +196,7 @@ count points in each polygon of the districts layer ```text buffer the roads by 100 meters buffer the roads by 100 m, then clip the buffer to the county boundary +create a Model Builder model that buffers roads by 100 m, clips the result to counties, and names the output Road buffers dissolve the parcels by zoning type find where the floodplain overlaps the buildings (intersection) create an H3 hex grid at resolution 8 over the points and count points per cell diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index c3b0a64b04..6a3655d8fb 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -342,6 +342,8 @@ export interface AppState { batchToolsOpen: boolean; /** The Model Builder canvas panel (author a processing graph). */ modelBuilderOpen: boolean; + /** One-shot request for Model Builder to load a saved model. */ + modelBuilderRequestedModelId: string | null; /** Style Manager dialog visibility (issue #1294). */ styleManagerOpen: boolean; /** Processing History panel visibility (#1292). */ @@ -465,6 +467,7 @@ export interface AppState { setStorymapComposing: (chapterId: string | null) => 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 } })), @@ -2236,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(); @@ -2296,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 new file mode 100644 index 0000000000..fadc6186d7 --- /dev/null +++ b/tests/assistant-model-builder.test.ts @@ -0,0 +1,441 @@ +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" }], + // `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 = { + 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: [ + { 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. */ +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: [], +}; + +/** 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" }, +] 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" }, + parameters: { distance: 100 }, + }, + ], + 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/, + ); + }); + + 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("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("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", + 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/, + ); + }); + + 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: .+/, + ); + }); +}); 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"); + }); +});