From 155ccd016e9fca3f8b72d2b31c8349a5a32ec9e3 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 6 Jul 2026 10:19:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20KI-Workflow-Assistent=20(Claude=20API)?= =?UTF-8?q?=20zum=20Erzeugen=20und=20=C3=84ndern=20von=20Workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neuer WorkflowAssistantService: übersetzt frei formulierte Wünsche per Claude API in das Task-JSON des Editors (Anthropic-SDK, Key aus .env) - Bestehende Workflows änderbar: aktueller Canvas wird als Kontext mitgegeben (inkl. Start-/Abschluss-Marker, ohne Validierung), Claude wendet die Änderungen an und liefert den vollständigen Workflow zurück - Assistenten-Dialog passt sich an (Erzeugen vs. Ändern), Claude-Aufruf läuft im Hintergrund mit UI-Polling - Canvas-Neuaufbau legt alte Blöcke in den Undo-Papierkorb (Strg+Z) - Button "App-JSON (stadtbote)" samt Vorschau-Dialog entfernt - Version 0.9.2 Co-Authored-By: Claude Fable 5 --- pom.xml | 9 +- .../service/TaskListExporter.java | 22 +- .../service/TaskListService.java | 15 + .../service/WorkflowAssistantService.java | 275 ++++++++++++++++++ .../tasklisteditor/view/MainView.java | 235 ++++++++++----- src/main/resources/application.properties | 6 + 6 files changed, 493 insertions(+), 69 deletions(-) create mode 100644 src/main/java/de/assecutor/tasklisteditor/service/WorkflowAssistantService.java diff --git a/pom.xml b/pom.xml index ddd66fe..2e4e1d2 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ de.assecutor tasklist-editor - 0.9.0 + 0.9.2 TaskList Editor n8n-inspired editor for task list workflows @@ -57,6 +57,13 @@ 4.0.0 + + + com.anthropic + anthropic-java + 2.48.0 + + org.springframework.boot spring-boot-devtools diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java index ef94aad..3a57085 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java @@ -38,6 +38,22 @@ public class TaskListExporter { } public List export(String drawflowJson, Map instances) { + return export(drawflowJson, instances, false); + } + + /** + * Variante für den KI-Assistenten: enthält auch die virtuellen Start-/ + * Abschluss-Markierungen (damit das Modell den vollständigen Ablauf sieht + * und sie im geänderten Workflow erhält) und verzichtet auf die + * Vollständigkeits-Validierung, damit auch unfertige Workflows als + * Kontext dienen können. + */ + public List exportForAssistant(String drawflowJson, Map instances) { + return export(drawflowJson, instances, true); + } + + private List export(String drawflowJson, Map instances, + boolean forAssistant) { List tasks = new ArrayList<>(); if (drawflowJson == null || drawflowJson.isBlank()) { return tasks; @@ -51,7 +67,9 @@ public class TaskListExporter { } // Ein vollständiger Workflow muss mit einem Start- und einem Abschluss- // Element gerahmt und beide müssen verdrahtet sein. - validateWorkflow(data, instances); + if (!forAssistant) { + validateWorkflow(data, instances); + } if (!data.isObject() || data.isEmpty()) { return tasks; @@ -67,7 +85,7 @@ public class TaskListExporter { } // Rein virtuelle Blöcke (Start/Abschluss) sind nur Workflow-Markierungen // und gehören nicht in die exportierte Task-Liste. - if (instance.type().isVirtual()) { + if (!forAssistant && instance.type().isVirtual()) { continue; } TaskDto task = toTask(instance); diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java index 017566f..d931374 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java @@ -56,6 +56,21 @@ public class TaskListService { } } + /** + * Baut das Task-JSON des aktuellen Canvas als Kontext für den + * KI-Assistenten: inklusive der Start-/Abschluss-Markierungen und ohne + * Vollständigkeits-Validierung, damit auch unfertige Workflows geändert + * werden können. + */ + public String buildAssistantContext(String drawflowJson, Map instances) { + List tasks = exporter.exportForAssistant(drawflowJson, instances); + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tasks); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Task-JSON konnte nicht erzeugt werden: " + ex.getMessage(), ex); + } + } + /** * Baut aus dem Editor-Zustand das app-konforme Task-Array, wie es die * stadtbote-App verarbeitet (entspricht der {@code tasks}-Liste einer Tour). diff --git a/src/main/java/de/assecutor/tasklisteditor/service/WorkflowAssistantService.java b/src/main/java/de/assecutor/tasklisteditor/service/WorkflowAssistantService.java new file mode 100644 index 0000000..f7c5729 --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/service/WorkflowAssistantService.java @@ -0,0 +1,275 @@ +package de.assecutor.tasklisteditor.service; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; +import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.ThinkingConfigAdaptive; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.tasklisteditor.model.BlockType; +import de.assecutor.tasklisteditor.model.BlockTypeRegistry; +import de.assecutor.tasklisteditor.model.PropertyDefinition; +import de.assecutor.tasklisteditor.model.TaskDto; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * KI-Workflow-Assistent: Übersetzt frei formulierte Benutzerwünsche + * (Reihenfolge der Elemente und deren Einstellungen) per Claude API in das + * {@code tasks}-JSON des Editors. Optional wird der aktuelle Workflow als + * Kontext mitgegeben, sodass Claude gezielte Änderungen daran vornimmt statt + * neu zu erzeugen. Das Ergebnis läuft über die vorhandene Import-Pipeline + * ({@link TaskListImporter}) und baut so den Canvas auf. + */ +@Service +public class WorkflowAssistantService { + + private final BlockTypeRegistry registry; + private final String apiKey; + private final String model; + private final ObjectMapper lenientMapper; + + private volatile AnthropicClient client; + + public WorkflowAssistantService(BlockTypeRegistry registry, + @Value("${tasklist.anthropic-api-key:}") String apiKey, + @Value("${tasklist.anthropic-model:claude-opus-4-8}") String model) { + this.registry = registry; + this.apiKey = apiKey == null ? "" : apiKey.trim(); + this.model = model; + this.lenientMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** Ergebnis des Assistenten: Workflow-Name plus Tasks in Reihenfolge. */ + public record WorkflowDraft(String name, List tasks) { + } + + /** Ob der Assistent nutzbar ist (API-Key in der .env hinterlegt). */ + public boolean isEnabled() { + return !apiKey.isBlank(); + } + + /** + * Erzeugt aus dem Benutzerwunsch einen neuen Workflow-Entwurf. Blockiert + * bis zur Antwort der Claude API (typisch einige Sekunden bis wenige + * Minuten) und sollte daher außerhalb des UI-Threads aufgerufen werden. + * + * @throws IllegalStateException wenn kein API-Key konfiguriert ist oder + * die Antwort nicht verwertbar ist + */ + public WorkflowDraft generateWorkflow(String wish) { + return generateWorkflow(wish, null, null); + } + + /** + * Erzeugt oder ändert einen Workflow. Ist {@code currentTasksJson} + * gesetzt, erhält Claude den aktuellen Workflow als Kontext, wendet die + * beschriebenen Änderungen darauf an und liefert den vollständigen, + * aktualisierten Workflow zurück. Blockiert bis zur Antwort der Claude API + * und sollte daher außerhalb des UI-Threads aufgerufen werden. + * + * @param currentName Name des aktuellen Workflows (optional) + * @param currentTasksJson Task-JSON des aktuellen Canvas oder {@code null} + * für einen neuen Workflow + * @throws IllegalStateException wenn kein API-Key konfiguriert ist oder + * die Antwort nicht verwertbar ist + */ + public WorkflowDraft generateWorkflow(String wish, String currentName, String currentTasksJson) { + if (!isEnabled()) { + throw new IllegalStateException( + "Kein Claude-API-Key konfiguriert (ANTHROPIC_API_KEY in der .env-Datei)."); + } + if (wish == null || wish.isBlank()) { + throw new IllegalStateException("Bitte einen Workflow-Wunsch eingeben."); + } + + MessageCreateParams params = MessageCreateParams.builder() + .model(model) + .maxTokens(16000L) + .thinking(ThinkingConfigAdaptive.builder().build()) + .system(buildSystemPrompt()) + .addUserMessage(buildUserMessage(wish, currentName, currentTasksJson)) + .build(); + + Message message = client().messages().create(params); + String text = extractText(message); + if (text.isBlank()) { + throw new IllegalStateException("Die KI hat keine verwertbare Antwort geliefert" + + " (Stop-Grund: " + message.stopReason() + ")."); + } + return parseDraft(text); + } + + private AnthropicClient client() { + AnthropicClient existing = client; + if (existing != null) { + return existing; + } + synchronized (this) { + if (client == null) { + client = AnthropicOkHttpClient.builder() + .apiKey(apiKey) + .build(); + } + return client; + } + } + + /** + * Baut die Benutzernachricht: nur der Wunsch (neuer Workflow) oder der + * aktuelle Workflow als Kontext plus die gewünschten Änderungen. + */ + private String buildUserMessage(String wish, String currentName, String currentTasksJson) { + if (currentTasksJson == null || currentTasksJson.isBlank()) { + return wish; + } + StringBuilder msg = new StringBuilder("Aktueller Workflow"); + if (currentName != null && !currentName.isBlank()) { + msg.append(" „").append(currentName).append('“'); + } + return msg.append(":\n").append(currentTasksJson) + .append("\n\nGewünschte Änderungen:\n").append(wish) + .toString(); + } + + private String extractText(Message message) { + StringBuilder text = new StringBuilder(); + for (ContentBlock block : message.content()) { + block.text().ifPresent(t -> text.append(t.text())); + } + return text.toString().trim(); + } + + /** + * Parst die Modell-Antwort. Erwartet ein JSON-Objekt + * {@code {"name": "...", "tasks": [...]}}; Markdown-Zäune und umgebender + * Text werden toleriert. + */ + private WorkflowDraft parseDraft(String text) { + String json = extractJsonObject(text); + try { + JsonNode root = lenientMapper.readTree(json); + JsonNode tasksNode = root.path("tasks"); + if (!tasksNode.isArray() || tasksNode.isEmpty()) { + throw new IllegalStateException("Die KI-Antwort enthält kein tasks-Array."); + } + List tasks = new ArrayList<>(); + for (JsonNode taskNode : tasksNode) { + tasks.add(lenientMapper.treeToValue(taskNode, TaskDto.class)); + } + String name = root.path("name").asText(""); + return new WorkflowDraft(name.isBlank() ? null : name, tasks); + } catch (IllegalStateException ex) { + throw ex; + } catch (Exception ex) { + throw new IllegalStateException( + "Die KI-Antwort konnte nicht als Workflow gelesen werden: " + ex.getMessage(), ex); + } + } + + /** Schneidet das erste JSON-Objekt aus der Antwort (entfernt ```-Zäune u. Ä.). */ + private String extractJsonObject(String text) { + int start = text.indexOf('{'); + int end = text.lastIndexOf('}'); + if (start < 0 || end <= start) { + throw new IllegalStateException("Die KI-Antwort enthält kein JSON-Objekt."); + } + return text.substring(start, end + 1); + } + + /** + * Baut die Systemanweisung: beschreibt die verfügbaren Elemente (aus der + * {@link BlockTypeRegistry}, inkl. benutzerdefinierter Typen) und das + * erwartete JSON-Format. + */ + private String buildSystemPrompt() { + StringBuilder prompt = new StringBuilder(); + prompt.append(""" + Du bist der Workflow-Assistent eines grafischen Editors für Auftrags-Workflows + (Task-Listen der stadtbote-App). Der Benutzer beschreibt in freier Sprache, + welche Elemente sein Workflow in welcher Reihenfolge enthalten soll und wie + sie eingestellt sein sollen. Du übersetzt das in ein Task-JSON. + + Es gibt zwei Fälle: + - Neuer Workflow: Die Nachricht enthält nur die Wünsche des Benutzers. + - Änderung: Der Nachricht ist unter „Aktueller Workflow“ das bestehende + Task-JSON vorangestellt. Wende die gewünschten Änderungen darauf an und + gib den VOLLSTÄNDIGEN aktualisierten Workflow zurück: Übernimm alle nicht + betroffenen Tasks unverändert – inklusive aller vorhandenen Felder, auch + hier nicht dokumentierter (z. B. "script") –, und vergib die "s_id"-Werte + anschließend lückenlos neu ab 1. Auch der geänderte Workflow muss mit dem + Start-Marker (type -1) beginnen und mit dem Abschluss-Marker (type -2) + enden – ergänze sie, falls sie im aktuellen Workflow fehlen. Behalte den + bisherigen Workflow-Namen bei, sofern der Benutzer keinen neuen verlangt. + + Antworte AUSSCHLIESSLICH mit einem einzigen JSON-Objekt in dieser Form, + ohne Markdown-Zäune und ohne erklärenden Text: + {"name": "", "tasks": [ ]} + + Regeln für die Tasks: + - Jeder Task hat "s_id" (fortlaufend ab 1), "type" (Typnummer, siehe Liste) + und "topic" (kurze, dem Bearbeiter angezeigte Bezeichnung; nutze die Angaben + des Benutzers, sonst die Standardbezeichnung des Typs). + - Der erste Task ist immer der Start-Marker (type -1, topic "Start"), der + letzte immer der Abschluss-Marker (type -2, topic "Abschluss"), sofern der + Benutzer nichts anderes verlangt. + - Setze nur die Felder, die für den jeweiligen Typ dokumentiert sind, und nur + wenn sie vom Standard abweichen oder der Benutzer sie nennt. + - "opt" (boolean) markiert eine insgesamt optionale Aufgabe, "reenter" + (boolean) erlaubt erneutes Bearbeiten. + - Listenfelder: + * "fields": Array aus {"title": "...", "placeholder": "...", "required": true|false} + (Text-/Zahlenlisten, Typ 12/13). + * "entries": Array aus {"code": , "text": "...", "required": true|false} + (Checkbox-Liste, Typ 14). + * "choices": Array aus {"txt": "...", "ret": } + (Statusauswahl, Typ 11). + Kennzeichne Einträge nur dann mit "required": true, wenn der Benutzer sie + als Pflicht bezeichnet. + - Erfinde keine Elemente, die der Benutzer nicht verlangt hat; wähle bei + unklaren Angaben die naheliegendste, einfache Lösung. + + Verfügbare Element-Typen: + """); + + for (BlockType type : registry.all()) { + if ("script".equals(type.id())) { + continue; // Script-Blöcke kann der Assistent nicht sinnvoll konfigurieren. + } + prompt.append("- type ").append(type.taskType()) + .append(" „").append(type.label()).append('“'); + if (type.description() != null && !type.description().isBlank()) { + prompt.append(": ").append(type.description()); + } + String fields = fieldDocs(type); + if (!fields.isEmpty()) { + prompt.append(" Felder: ").append(fields); + } + prompt.append('\n'); + } + return prompt.toString(); + } + + private String fieldDocs(BlockType type) { + List docs = new ArrayList<>(); + for (PropertyDefinition def : type.properties()) { + String doc = switch (def.type()) { + case BOOLEAN -> "\"" + def.id() + "\" (boolean – " + def.label() + ")"; + case NUMBER -> "\"" + def.id() + "\" (Zahl – " + def.label() + ")"; + case TEXT, TEXTAREA -> "\"" + def.id() + "\" (Text – " + def.label() + ")"; + case FIELD_LIST -> "\"fields\" (" + def.label() + ")"; + case NAME_LIST -> "\"entries\" (" + def.label() + ")"; + case CHOICE_LIST -> "\"choices\" (" + def.label() + ")"; + }; + docs.add(doc); + } + return String.join(", ", docs); + } +} diff --git a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java index e674c97..823ead6 100644 --- a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java +++ b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java @@ -1,6 +1,7 @@ package de.assecutor.tasklisteditor.view; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.checkbox.Checkbox; @@ -18,6 +19,7 @@ import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.progressbar.ProgressBar; import com.vaadin.flow.component.textfield.IntegerField; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.component.textfield.TextField; @@ -33,6 +35,7 @@ import de.assecutor.tasklisteditor.model.BlockType; import de.assecutor.tasklisteditor.model.BlockTypeRegistry; import de.assecutor.tasklisteditor.model.CanvasBlockState; import de.assecutor.tasklisteditor.model.PropertyDefinition; +import de.assecutor.tasklisteditor.model.TaskDto; import de.assecutor.tasklisteditor.model.TaskListPayload; import de.assecutor.tasklisteditor.document.CanvasDocument; import de.assecutor.tasklisteditor.service.CanvasService; @@ -41,6 +44,7 @@ import de.assecutor.tasklisteditor.service.ElementSettingsService; import de.assecutor.tasklisteditor.service.PendingImportStore; import de.assecutor.tasklisteditor.service.TaskListImporter; import de.assecutor.tasklisteditor.service.TaskListService; +import de.assecutor.tasklisteditor.service.WorkflowAssistantService; import elemental.json.Json; import elemental.json.JsonArray; import elemental.json.JsonObject; @@ -60,6 +64,8 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; @Route("") @PageTitle("Workflow Editor") @@ -75,6 +81,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { private final CanvasService canvasService; private final CustomTypeService customTypeService; private final ElementSettingsService elementSettingsService; + private final WorkflowAssistantService workflowAssistantService; private final ObjectMapper objectMapper; private final NodeEditor nodeEditor; private final Map instances = new HashMap<>(); @@ -101,6 +108,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { CanvasService canvasService, CustomTypeService customTypeService, ElementSettingsService elementSettingsService, + WorkflowAssistantService workflowAssistantService, ObjectMapper objectMapper, @Value("${tasklist.app-version:}") String appVersion) { this.appVersion = appVersion; @@ -111,6 +119,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { this.canvasService = canvasService; this.customTypeService = customTypeService; this.elementSettingsService = elementSettingsService; + this.workflowAssistantService = workflowAssistantService; this.objectMapper = objectMapper; setSizeFull(); setPadding(false); @@ -216,6 +225,11 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { title.add(version); } + Button assistant = new Button("KI-Assistent", new Icon(VaadinIcon.MAGIC)); + assistant.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + assistant.setTooltipText("Workflow aus einer Beschreibung erzeugen oder ändern (Claude)"); + assistant.addClickListener(e -> openAssistantDialog()); + Button undo = new Button("Rückgängig", new Icon(VaadinIcon.ARROW_BACKWARD)); undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY); undo.setTooltipText("Letzte Canvas-Änderung zurücknehmen (Strg+Z, max. 10 Schritte)"); @@ -243,10 +257,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { loadCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY); loadCanvas.addClickListener(e -> openLoadCanvasDialog()); - Button appJson = new Button("App-JSON (stadtbote)", new Icon(VaadinIcon.MOBILE)); - appJson.addThemeVariants(ButtonVariant.LUMO_SUCCESS); - appJson.addClickListener(e -> previewAppJson()); - Button export = new Button("JSON erzeugen & speichern", new Icon(VaadinIcon.DOWNLOAD)); export.addThemeVariants(ButtonVariant.LUMO_PRIMARY); export.addClickListener(e -> exportTasks()); @@ -254,7 +264,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { Span spacer = new Span(); spacer.getStyle().set("flex", "1"); - header.add(title, spacer, undo, clear, saveCanvas, loadCanvas, appJson, export); + header.add(title, spacer, assistant, undo, clear, saveCanvas, loadCanvas, export); return header; } @@ -958,11 +968,23 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { /** Baut den Canvas aus den per Deep-Link übergebenen Daten auf. */ private void loadPayload(TaskListPayload payload) { - List resolved = taskListImporter.resolve(payload.getTasks()); + buildCanvasFromTasks(payload.getTasks(), payload.getName(), "aus Deep-Link geladen"); + } + + /** + * Baut den Canvas aus einer Task-Liste neu auf (Knoten in Reihenfolge plus + * Verkettung) und legt die zugehörigen {@link BlockInstance}s an. Wird vom + * Deep-Link-Import und vom KI-Assistenten genutzt. + */ + private void buildCanvasFromTasks(List tasks, String name, String successHint) { + List resolved = taskListImporter.resolve(tasks); if (resolved.isEmpty()) { + Notification.show("Keine verwertbaren Elemente gefunden."); return; } - currentListName = payload.getName(); + if (name != null && !name.isBlank()) { + currentListName = name; + } JsonArray descriptors = Json.createArray(); for (int i = 0; i < resolved.size(); i++) { @@ -973,12 +995,15 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { descriptors.set(i, obj); } + // In den Papierkorb statt verwerfen: Undo kann den Neuaufbau zurücknehmen. + removedInstances.putAll(instances); instances.clear(); - nodeEditor.importTasks(descriptors, idsJson -> applyImportedIds(idsJson, resolved)); + nodeEditor.importTasks(descriptors, idsJson -> applyImportedIds(idsJson, resolved, successHint)); } - private void applyImportedIds(String idsJson, List resolved) { + private void applyImportedIds(String idsJson, List resolved, + String successHint) { int[] ids; try { ids = objectMapper.readValue(idsJson, int[].class); @@ -994,7 +1019,141 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { task.values().forEach(instance::set); instances.put(ids[i], instance); } - Notification.show(count + " Task(s) aus Deep-Link geladen."); + Notification.show(count + " Task(s) " + successHint + "."); + } + + // --- KI-Workflow-Assistent (Claude API) --- + + /** + * Dialog des KI-Assistenten: Der Benutzer beschreibt in freier Sprache die + * gewünschten Elemente, deren Reihenfolge und Einstellungen; Claude + * übersetzt das in eine Task-Liste, aus der der Canvas aufgebaut wird. + * Liegt bereits ein Workflow auf dem Canvas, wird er als Kontext + * mitgegeben und die Beschreibung als Änderungswunsch angewendet. + */ + private void openAssistantDialog() { + if (!workflowAssistantService.isEnabled()) { + Notification.show("KI-Assistent nicht verfügbar: Bitte ANTHROPIC_API_KEY " + + "in der .env-Datei hinterlegen und die Anwendung neu starten."); + return; + } + boolean modifying = !instances.isEmpty(); + + Dialog dialog = new Dialog(); + dialog.setHeaderTitle(modifying + ? "KI-Assistent: Workflow ändern" + : "KI-Assistent: Workflow erzeugen"); + dialog.setWidth("640px"); + + Paragraph hint = new Paragraph(modifying + ? "Beschreibe die gewünschten Änderungen am aktuellen Workflow. Beispiel: " + + "„Nach der Ankunft zwei Pflicht-Fotos einfügen, die Bemerkung optional " + + "machen und die Statusauswahl um ‚Beschädigt‘ ergänzen.“" + : "Beschreibe den gewünschten Workflow: welche Elemente " + + "in welcher Reihenfolge und mit welchen Einstellungen. Beispiel: " + + "„Ankunft mit GPS erfassen, dann drei Pflicht-Fotos, Statusauswahl " + + "mit ‚OK‘ und ‚Beschädigt‘, zum Schluss Unterschrift.“"); + hint.addClassName("properties-desc"); + + TextArea wishes = new TextArea(modifying + ? "Gewünschte Änderungen" + : "Wünsche für den Workflow"); + wishes.setWidthFull(); + wishes.setMinHeight("160px"); + wishes.setPlaceholder(modifying + ? "z. B. Unterschrift ans Ende verschieben, Fotos als Pflicht markieren …" + : "z. B. Ankunft melden, danach Fotos (mind. 2), Bemerkung optional …"); + + ProgressBar progress = new ProgressBar(); + progress.setIndeterminate(true); + progress.setVisible(false); + Span progressLabel = new Span(modifying + ? "Claude ändert den Workflow – das kann einen Moment dauern …" + : "Claude erstellt den Workflow – das kann einen Moment dauern …"); + progressLabel.setVisible(false); + + VerticalLayout content = new VerticalLayout(hint, wishes, progress, progressLabel); + content.setPadding(false); + content.setSpacing(true); + dialog.add(content); + + Button cancel = new Button("Abbrechen", ev -> dialog.close()); + cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + Button generate = new Button(modifying ? "Änderungen anwenden" : "Workflow erzeugen", + new Icon(VaadinIcon.MAGIC)); + generate.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + generate.addClickListener(ev -> { + String wish = wishes.getValue(); + if (wish == null || wish.isBlank()) { + Notification.show(modifying + ? "Bitte zuerst die gewünschten Änderungen beschreiben." + : "Bitte zuerst die Wünsche für den Workflow beschreiben."); + return; + } + generate.setEnabled(false); + wishes.setReadOnly(true); + progress.setVisible(true); + progressLabel.setVisible(true); + + UI ui = UI.getCurrent(); + ui.setPollInterval(500); + if (modifying) { + // Aktuellen Canvas als Task-JSON exportieren und als Kontext mitgeben. + nodeEditor.exportGraph(graphJson -> { + String currentJson; + try { + currentJson = taskListService.buildAssistantContext(graphJson, instances); + } catch (Exception ex) { + ui.setPollInterval(-1); + Notification.show("KI-Assistent fehlgeschlagen: " + ex.getMessage()); + generate.setEnabled(true); + wishes.setReadOnly(false); + progress.setVisible(false); + progressLabel.setVisible(false); + return; + } + runAssistant(dialog, generate, wishes, progress, progressLabel, ui, + "per KI-Assistent geändert", + () -> workflowAssistantService.generateWorkflow(wish, currentListName, currentJson)); + }); + } else { + runAssistant(dialog, generate, wishes, progress, progressLabel, ui, + "per KI-Assistent erstellt", + () -> workflowAssistantService.generateWorkflow(wish)); + } + }); + + dialog.getFooter().add(cancel, generate); + dialog.open(); + wishes.focus(); + } + + /** + * Führt den Claude-Aufruf im Hintergrund aus (dauert typisch einige + * Sekunden bis Minuten); Polling holt das Ergebnis in die UI. Bei Erfolg + * wird der Canvas aus dem gelieferten Entwurf neu aufgebaut. + */ + private void runAssistant(Dialog dialog, Button generate, TextArea wishes, + ProgressBar progress, Span progressLabel, UI ui, + String successHint, + Supplier call) { + CompletableFuture + .supplyAsync(call) + .whenComplete((draft, error) -> ui.access(() -> { + ui.setPollInterval(-1); + if (error != null) { + Throwable cause = error.getCause() != null ? error.getCause() : error; + Notification.show("KI-Assistent fehlgeschlagen: " + cause.getMessage()); + generate.setEnabled(true); + wishes.setReadOnly(false); + progress.setVisible(false); + progressLabel.setVisible(false); + return; + } + buildCanvasFromTasks(draft.tasks(), draft.name(), successHint); + dialog.close(); + })); } // --- Canvas speichern / laden (MongoDB) --- @@ -1177,60 +1336,4 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { dialog.open(); } - /** - * Erzeugt das vollständige, von der stadtbote-App verarbeitbare Job-JSON - * (Job → Tour → Tasks) und zeigt es in einem Dialog an. - */ - private void previewAppJson() { - if (instances.isEmpty()) { - Notification.show("Es sind keine Funktionsblöcke auf dem Canvas."); - return; - } - nodeEditor.exportGraph(graphJson -> { - try { - TaskListService.AppJobResult result = - taskListService.buildAppJob(currentListName, graphJson, instances); - if (result.taskCount() == 0) { - Notification.show("Keine app-verarbeitbaren Tasks gefunden."); - return; - } - openAppJsonDialog(result); - } catch (Exception ex) { - Notification.show("App-JSON fehlgeschlagen: " + ex.getMessage()); - } - }); - } - - private void openAppJsonDialog(TaskListService.AppJobResult result) { - Dialog dialog = new Dialog(); - dialog.setHeaderTitle("App-JSON für stadtbote (" + result.taskCount() + " Tasks)"); - dialog.setWidth("720px"); - - Paragraph hint = new Paragraph("Task-Array, das von der stadtbote-App fehlerfrei " - + "verarbeitet wird. Script-Blöcke werden ausgelassen."); - hint.addClassName("properties-desc"); - - TextArea json = new TextArea("App-JSON (tasks-Array)"); - json.setWidthFull(); - json.setHeight("420px"); - json.setReadOnly(true); - json.setValue(result.json()); - - VerticalLayout content = new VerticalLayout(hint, json); - content.setPadding(false); - content.setSpacing(true); - dialog.add(content); - - Button copy = new Button("In Zwischenablage kopieren", new Icon(VaadinIcon.COPY), ev -> { - getElement().executeJs("navigator.clipboard.writeText($0)", result.json()); - Notification.show("JSON in die Zwischenablage kopiert."); - }); - copy.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - - Button close = new Button("Schließen", ev -> dialog.close()); - close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - - dialog.getFooter().add(close, copy); - dialog.open(); - } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1dacc45..804e44e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -30,6 +30,12 @@ management.health.mongo.enabled=false # Wird per .env (HIDE_SCRIPT_BLOCK) gesteuert. tasklist.hide-script-block=${HIDE_SCRIPT_BLOCK:false} +# --- KI-Workflow-Assistent (Claude API) -------------------------------------- +# API-Key und Modell kommen aus der .env-Datei (ANTHROPIC_API_KEY, +# ANTHROPIC_MODEL). Ohne Key ist der Assistent im Editor deaktiviert. +tasklist.anthropic-api-key=${ANTHROPIC_API_KEY:} +tasklist.anthropic-model=${ANTHROPIC_MODEL:claude-opus-4-8} + # --- Collections je Element-Typ --------------------------------------------- # Einstellungen der einzelnen Elemente werden beim Canvas-Speichern zusätzlich # je Element-Typ in eine eigene Collection geschrieben. Namen aus der .env