feat: Phase 5 - Visual Workflow Editor (Option B)
Some checks failed
Deploy Development / deploy (push) Failing after 1s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 14s

Backend (Mini-Backend 1-2h):
- Migration 016: ai_prompts.graph_data JSONB column
- workflow_executor: graph_data parameter support (backward-compatible)
- prompt_executor: execute_workflow_prompt uses graph_data

Frontend (Main effort 25-35h):
- WorkflowCanvas: React Flow wrapper component
- 5 Custom Nodes: Start, End, Analysis, Logic, Join
- 4 Config Panels: QuestionAugmentation, LogicExpression, Fallback, Join
- workflowValidation: Structural + logical validation
- workflowSerializer: Canvas ↔ JSONB conversion
- WorkflowEditorPage: Main orchestration (420 LOC)
- Route: /workflow-editor/:id
- CSS: workflowEditor.css (300 LOC)

Architecture:
- Option B: ai_prompts.type='workflow' (not separate table)
- panels/ subdirectory for clean separation
- WorkflowCanvas reusable component
- User GUI identical (Workflows = Prompts)
- Backward-compatible (type='pipeline' unchanged)

Version: v0.9m → v0.9n (Phase 5 complete)
Module: workflow 0.5.0 → 0.6.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-04-04 17:56:00 +02:00
parent a7058c30be
commit dc59596f01
23 changed files with 3010 additions and 48 deletions

View File

@ -0,0 +1,35 @@
-- Migration 016: Workflow Support in ai_prompts (Phase 5)
-- Erweitert ai_prompts für type='workflow' (Option B - Backward-kompatibel)
-- Neue Spalte für Workflow-Graphen
ALTER TABLE ai_prompts ADD COLUMN graph_data JSONB;
-- Index für Workflow-Queries
CREATE INDEX IF NOT EXISTS idx_ai_prompts_type ON ai_prompts(type);
CREATE INDEX IF NOT EXISTS idx_ai_prompts_graph_data ON ai_prompts USING GIN (graph_data);
-- Kommentar
COMMENT ON COLUMN ai_prompts.graph_data IS 'Workflow-Graph (nur für type=workflow): {nodes: [...], edges: [...], metadata: {...}}';
-- Beispiel-Struktur (Dokumentation):
-- {
-- "nodes": [
-- {"id": "start", "type": "start", "label": "Start", "position": {"x": 100, "y": 100}},
-- {"id": "analysis_1", "type": "analysis", "prompt_id": 5, "questions": [...], ...},
-- {"id": "logic_1", "type": "logic", "condition": {...}, ...},
-- {"id": "join_1", "type": "join", "join_strategy": "wait_all", ...},
-- {"id": "end", "type": "end", "label": "Ende", "position": {"x": 500, "y": 300}}
-- ],
-- "edges": [
-- {"id": "e1", "source": "start", "target": "analysis_1"},
-- {"id": "e2", "source": "analysis_1", "target": "logic_1"},
-- {"id": "e3", "source": "logic_1", "target": "join_1"},
-- {"id": "e4", "source": "join_1", "target": "end"}
-- ],
-- "metadata": {
-- "created_at": "2026-04-04T12:00:00Z",
-- "version": "1.0"
-- }
-- }
-- Migration erfolgreich

View File

@ -588,11 +588,11 @@ async def execute_workflow_prompt(
"""
Execute a workflow-type prompt (graph-based execution).
Phase 2: Sequenzielle Workflow-Execution (ohne Logik/Routing)
Phase 3: Conditional branching
Phase 2-4: Sequenzielle Workflow-Execution, conditional branching, path consolidation
Phase 5: Graph aus ai_prompts.graph_data (nicht workflow_definitions)
Args:
prompt: Prompt dict from database (must have 'id' field for workflow_id)
prompt: Prompt dict from database (must have 'graph_data' field)
variables: Dict of variables for placeholder replacement
openrouter_call_func: Async function(prompt_text, model) -> response_text
enable_debug: If True, include debug information in response
@ -611,13 +611,14 @@ async def execute_workflow_prompt(
"""
from workflow_executor import execute_workflow
workflow_id = prompt.get('id')
if not workflow_id:
raise HTTPException(400, "Workflow-Prompt fehlt 'id' Feld")
# Phase 5: Graph aus ai_prompts.graph_data
graph_data = prompt.get('graph_data')
if not graph_data:
raise HTTPException(400, "Workflow-Prompt fehlt 'graph_data' Feld")
# Execute workflow
# Execute workflow (mit graph_data statt workflow_id)
result = await execute_workflow(
workflow_id=workflow_id,
graph_data=graph_data, # NEU: Direkt graph_data übergeben
profile_id=variables.get('profile_id', 'unknown'), # From context
variables=variables,
openrouter_call_func=openrouter_call_func,

View File

@ -35,10 +35,11 @@ logger = logging.getLogger(__name__)
async def execute_workflow(
workflow_id: str,
profile_id: str,
variables: Dict[str, Any],
openrouter_call_func, # Callback für LLM-Calls: async (prompt, model) -> str
workflow_id: Optional[str] = None,
graph_data: Optional[Dict] = None, # Phase 5: Direkt von ai_prompts.graph_data
profile_id: str = None,
variables: Dict[str, Any] = None,
openrouter_call_func = None, # Callback für LLM-Calls: async (prompt, model) -> str
enable_debug: bool = False
) -> ExecutionResult:
"""
@ -47,9 +48,11 @@ async def execute_workflow(
Phase 2: Linear execution in topological order.
Phase 3: Conditional branching basierend auf logic nodes.
Phase 4: Join nodes und path consolidation.
Phase 5: Unterstützt graph_data direkt (aus ai_prompts, nicht workflow_definitions)
Args:
workflow_id: UUID des Workflows
workflow_id: UUID des Workflows (legacy, für workflow_definitions Tabelle)
graph_data: Workflow-Graph als Dict (NEU, für ai_prompts.graph_data)
profile_id: UUID des Profils
variables: Platzhalter-Werte (z.B. {"name": "Lars", ...})
openrouter_call_func: async (prompt, model) -> str
@ -58,25 +61,27 @@ async def execute_workflow(
Returns:
ExecutionResult mit allen node_states
Beispiel:
Beispiel (Phase 5):
>>> result = await execute_workflow(
... workflow_id="test-workflow",
... graph_data={"nodes": [...], "edges": [...]},
... profile_id="test-profile",
... variables={"name": "Lars"},
... openrouter_call_func=my_llm_func
... )
>>> result.status
'completed'
>>> len(result.node_states)
3
"""
execution_id = str(uuid.uuid4())
started_at = datetime.utcnow().isoformat()
logger.info(f"Starting workflow execution: {execution_id} (workflow: {workflow_id})")
logger.info(f"Starting workflow execution: {execution_id}")
try:
# 1. Lade Workflow-Definition
if graph_data:
# Phase 5: Graph direkt aus ai_prompts.graph_data
graph_json = json.dumps(graph_data)
logger.debug(f"Using provided graph_data")
elif workflow_id:
# Phase 0-4: Graph aus workflow_definitions Tabelle (legacy)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
@ -88,6 +93,9 @@ async def execute_workflow(
raise ValueError(f"Workflow not found: {workflow_id}")
graph_json = row['graph']
logger.debug(f"Loaded graph from workflow_definitions: {workflow_id}")
else:
raise ValueError("Entweder workflow_id oder graph_data muss übergeben werden")
# 2. Parse Graph
graph = parse_workflow_graph(graph_json)

View File

@ -0,0 +1,264 @@
# Phasenplan: Responsive UI (Desktop Sidebar + Mobile/PWA)
> **Gitea:** [#30 Responsive UI](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/30)
> **Spec:** `.claude/docs/functional/RESPONSIVE_UI.md`
> **Breakpoint:** `<1024px` = Mobile (Bottom-Nav, bestehendes Verhalten), `≥1024px` = Desktop (Sidebar 220px)
> **Letzte Plan-Aktualisierung:** 2026-04-04
---
## Fortschritt (kurz)
| Phase | Titel | Status | Datum / Notiz |
|-------|--------|--------|----------------|
| P0 | Vorbereitung & Baseline | ☐ pending | |
| P1 | App-Shell: Sidebar + Breakpoint + gemeinsame Navigation | ☐ pending | |
| P2 | Globales Layout & Content-Bereich (CSS) | ☐ pending | |
| P3 | Dashboard (Desktop-Grid) | ☐ pending | |
| P4 | Verlauf (Tabs links / Content rechts) | ☐ pending | |
| P5 | Analyse (Prompts links / Ergebnis rechts) | ☐ pending | |
| P6 | Erfassung / Capture & Formularseiten | ☐ pending | |
| P7 | Admin & restliche Vollbreiten-Seiten | ☐ pending | |
| P8 | Abschluss, Regression, Spec-Pflege | ☐ pending | |
**Status-Legende:** `☐ pending` · `◐ in Arbeit` · `☑ erledigt` · `⏸ blockiert`
---
## Testumgebung (für alle Phasen)
| ID | Umgebung | Verwendung |
|----|-----------|------------|
| T-H1 | Chromium Desktop, Fenster **≥1280px** | Desktop-Layout, Sidebar |
| T-H2 | Chromium, DevTools **375×812** (o. ä.) | Mobile-Layout, Bottom-Nav |
| T-H3 | Chromium, Breite **1023px** vs **1024px** | Breakpoint-Grenze |
| T-H4 | **iPhone** (Safari), installierte **PWA** | Regression Mobile/PWA |
| T-H5 | optional: iPad quer (**≥1024px**) | Desktop-Sidebar auf Tablet quer |
**Allgemein:** Kein Pflicht-E2E-Framework im Repo; Abnahme primär **manuell** nach Checklisten unten. Optional später: Playwright-Smoke (`viewport`-Wechsel).
---
## Phase P0 Vorbereitung & Baseline
### Ziel
Risikoarm starten: Ist-Stand dokumentieren, Spec bereinigen, keine funktionale UI-Änderung.
### Aufgaben
- [ ] Bekannte Artefakte am Ende von `.claude/docs/functional/RESPONSIVE_UI.md` entfernen (Zeilen `EOF` / `echo …`), falls noch vorhanden.
- [ ] Kurz notieren: aktuelle `app-shell`/`max-width:600px` in `app.css` ist die Mobile-Baseline (siehe Code-Review).
### Abnahmekriterien
- Spec-Datei endet mit den „Offenen Fragen“; keine Shell-Zeilenduplikate aus Fremdkopien.
- Plan-Datei (dieses Dokument) ist im Repo und verlinkt (optional in Gitea #30 kommentieren).
### Tests (P0)
| Test | Schritt | Erwartung |
|------|---------|-----------|
| P0-T1 | `RESPONSIVE_UI.md` öffnen | Letzte sinnvolle Zeile ist Frage 4 oder Abschnittsende; kein `echo`/`EOF` |
| P0-T2 | `npm run build` im `frontend/` | Exit 0 |
---
## Phase P1 App-Shell: Sidebar + Breakpoint + gemeinsame Navigation
### Ziel
Desktop: feste **Sidebar 220px** links gemäß Spec; Mobile: **unverändert** Bottom-Nav. **Eine** Navigationsdefinition (Routes/Labels/Icons) für beide Darstellungen. Öffentliche Auth-Routen (`/register`, `/verify`, …) **nicht** in Sidebar/Bottom-Nav einbinden (wie heute).
### Aufgaben (technisch orientierend)
- [ ] Nav-Items aus `App.jsx` (`Nav`-Komponente) in **eine** exportierbare Struktur (z. B. `navItems` Array + `admin`-Eintrag nur bei `role === 'admin'`), von **Desktop-Sidebar** und **Mobile `Nav`** gemeinsam genutzt.
- [ ] Neue Komponente z. B. `DesktopSidebar.jsx` (oder `AppSidebar.jsx`): Logo/Branding, gleiche Links wie Bottom-Nav, unten Nutzerbereich (Avatar, Name/Tier laut Spec Daten aus `useProfile`/`useAuth` wo möglich).
- [ ] `AppShell`: bei `≥1024px` Sidebar sichtbar, **Bottom-Nav ausgeblendet**; bei `<1024px` **umgekehrt**.
- [ ] Aktiver Route-State: `NavLink`/`active` in Sidebar **gleiche Semantik** wie Bottom-Nav (`end` bei `/` beachten).
- [ ] **Kein** `resize`-Listener pflichtig; primär **CSS** (`@media (min-width: 1024px)`) für Sichtbarkeit; falls nötig nur für Edge-Cases.
### Abnahmekriterien
- Unter **1024px**: UI wirkt **wie vor P1** (Bottom-Nav sichtbar, Sidebar unsichtbar); PWA-Start auf iPhone unverändert nutzbar.
- Ab **1024px**: Sidebar sichtbar, fixed links, ~220px; Bottom-Nav **nicht** sichtbar.
- Admin-Link nur für Admins in **beiden** Navs.
- Abmelden erreichbar (Spec: im Sidebar-Footer; falls Mobile weiter nur im Header: ** dokumentieren als Abweichung** oder gleich ziehen).
### Tests (P1)
| Test | Schritt | Erwartung |
|------|---------|-----------|
| P1-T1 | T-H2: einloggen, alle 5 Haupt-Routen antippen | Bottom-Nav aktiv, kein Layout-Bruch |
| P1-T2 | T-H1: gleiche Routen | Nur Sidebar-Navigation sichtbar, korrekte Aktiv-Markierung |
| P1-T3 | T-H3: Breite 1023→1024 wechseln | Sidebar erscheint / Bottom verschwindet ohne Reload; kein „Flackern“-Loop |
| P1-T4 | T-H4: PWA | Login, Hauptflows kurz (Dashboard, Erfassung) Bottom-Nav ok |
| P1-T5 | Admin-User T-H1 | Admin-Eintrag in Sidebar; Nicht-Admin ohne Admin |
---
## Phase P2 Globales Layout & Content-Bereich
### Ziel
Desktop-**Content** rechts von der Sidebar: Spec **margin-left 220px**, Padding **24px 32px**, **max-width 1200px** zentriert im verbleibenden Raum. Mobile: beibehaltene Abstände (**16px**, **80px** bottom für Nav).
### Aufgaben
- [ ] `app.css`: `.app-shell` **nicht** mehr global `max-width: 600px` für Desktop; Mobile beibehalten oder per Media Query trennen.
- [ ] `.app-main` Padding/Bottom: Mobile `calc(nav + 16px)`; Desktop **ohne** Bottom-Nav-Padding (oder nur safe-area falls nötig).
- [ ] Optional Desktop: **Top-Header** (`app-header`) redundant mit Sidebar-Branding **vereinheitlichen** (z. B. Header auf Desktop ausblenden oder auf kompakte Toolbar reduzieren), damit keine doppelte Logo-Zeile.
### Abnahmekriterien
- Desktop: Content nutzt **sichtbar mehr** horizontalen Raum als heute (max 1200px Inhalt, zentriert).
- Mobile: **kein** zusätzliches horizontales Scrollen der Gesamtseite durch P1/P2.
- Keine Überlappung Sidebar/Content.
### Tests (P2)
| Test | Schritt | Erwartung |
|------|---------|-----------|
| P2-T1 | T-H1: lange Seite scrollen | Nur Main-Area scrollt; Sidebar bleibt sichtbar (fixed) |
| P2-T2 | T-H2 | Weiterhin nutzbarer unterer Rand für Bottom-Nav |
| P2-T3 | T-H1: sehr breiter Monitor | Content max ~1200px, optisch zentriert rechts von Sidebar |
---
## Phase P3 Dashboard (Desktop-Grid)
### Ziel
Spec **§5.1**: Desktop mehrspaltige Karten (z. B. 4 Spalten für Kennzahlen-Zeile); Mobile Karten untereinander.
### Aufgaben
- [ ] `Dashboard.jsx`: Layout in **CSS Grid** o. ä. mit `@media (min-width: 1024px)` für 4-Spalten-Zeile(n) laut Wireframe; Mobile unveränderte Reihenfolge.
- [ ] Charts/„volle Breite“-Blöcke unter den Karten laut Spec.
### Abnahmekriterien
- Mobile Dashboard **visuell vergleichbar** zum Stand vor P3 (keine funktionalen Regressionen).
- Desktop: klar erkennbares **Grid**; keine übermäßigen Lücken bei typischer Viewport-Höhe.
### Tests (P3)
| Test | Schritt | Erwartung |
|------|---------|-----------|
| P3-T1 | T-H2 Dashboard | Karten untereinander, lesbar |
| P3-T2 | T-H1 Dashboard | Mehrspaltigkeit wie Spec; kein horizontaler Overflow |
| P3-T3 | Daten mit/ohne Werte | Keine Layout-Crashes (leere Zustände) |
---
## Phase P4 Verlauf (`History.jsx`)
### Ziel
Spec **§5.2**: Desktop **Tabs vertikal links**, Chart/Tabelle **rechts** (volle restliche Breite); Mobile Tabs oben wie heute.
### Aufgaben
- [ ] Tab-Steuerung refaktorieren oder per CSS **ohne** doppelte State-Logik.
- [ ] Desktop: flex/grid `240280px` Tab-Spalte + flex 1 Chart-Bereich (feinjustierbar).
### Abnahmekriterien
- Alle bisherigen Verlauf-**Tabs** funktionieren (Gewicht, KF, Umfänge, … wie im aktuellen Code).
- Mobile UX **unverändert** vom Nutzergefühl her (Tabs oben).
- Desktop: klar **zwei Spalten**; Chart nutzt rechte Fläche.
### Tests (P4)
| Test | Schritt | Erwartung |
|------|---------|-----------|
| P4-T1 | T-H2: Tab wechseln | Wie bisher |
| P4-T2 | T-H1: jeder Tab | Linke Liste + rechter Chart-Bereich sichtbar |
| P4-T3 | Langes Tab-Label / viele Tabs | Kein Layout-Bruch (Scroll in Tab-Spalte falls nötig) |
---
## Phase P5 Analyse (`Analysis.jsx`)
### Ziel
Spec **§5.3**: Desktop **Prompt-Liste links (~300px)**, Ergebnis **rechts**; Mobile untereinander.
### Aufgaben
- [ ] Layout splitten; Pipeline/Prompt-Bereiche so umbauen, dass Lesbarkeit und Scroll-Verhalten stimmen.
### Abnahmekriterien
- KI-Ausführung, Platzhalter-Tabelle, Experten-Modus: weiter bedienbar.
- Desktop: Ergebnisbereich hat **deutlich mehr** Breite als Mobile.
### Tests (P5)
| Test | Schritt | Erwartung |
|------|---------|-----------|
| P5-T1 | T-H2: Analyse ausführen | Flow wie bisher |
| P5-T2 | T-H1: langes Ergebnis | Rechte Spalte scrollbar, linke Prompt-Liste fix oder eigen-scroll |
| P5-T3 | Kleines Fenster knapp unter 1024px | Kein „mittendrin“ kaputtes Layout |
---
## Phase P6 Erfassung / Capture & Formularseiten
### Ziel
Spec **§5.4**: Desktop Formulare **zentriert**, **max-width ~600px** im Content-Bereich; Mobile volle Breite wie heute.
### Aufgaben
- [ ] `CaptureHub` und relevante Seiten (`WeightScreen`, …) unter Desktop-Wrapper; wo sinnvoll **gemeinsame** Klasse `.capture-form-desktop` in `app.css`.
### Abnahmekriterien
- Mobile: keine Verschlechterung der Eingabe.
- Desktop: Formulare nicht „volle 1200px“, sondern **lesbar begrenzt**.
### Tests (P6)
| Test | Schritt | Erwartung |
|------|---------|-----------|
| P6-T1 | T-H2: Gewicht erfassen | Vollbreite ok |
| P6-T2 | T-H1: gleiche Seite | Schmale, zentrierte Spalte |
| P6-T3 | Capture-Hub Kacheln | Umbruch auf Desktop ordentlich |
---
## Phase P7 Admin & übrige Seiten
### Ziel
Spec **§5.5**: Admin-Tabellen nutzen auf Desktop **mehr Breite**; Mobile weiterhin horizontales Scrollen wo nötig.
### Aufgaben
- [ ] `Admin*Page.jsx` und ähnliche Tabellen-Seiten: unnötige `max-width`-Erbe von Mobile entfernen; **nicht** jede Admin-Seite einzeln zerstören priorisiert häufig genutzte.
### Abnahmekriterien
- Keine regressiven Auth-Schutz-Änderungen.
- Desktop: **mehr sichtbare Spalten** bei typischen Admin-Tabellen.
### Tests (P7)
| Test | Schritt | Erwartung |
|------|---------|-----------|
| P7-T1 | T-H1: z. B. Admin Training Types | Tabelle nutzt Breite; horizontales Scrollen nur bei Bedarf |
| P7-T2 | T-H2: gleiche Seite | Weiter bedienbar mit Scroll |
---
## Phase P8 Abschluss, Regression, Dokumentation
### Ziel
Release-tauglich machen; Spec und Issue aktualisieren.
### Aufgaben
- [ ] Alle Phasen P1P7 in Fortschrittstabelle auf ☑ setzen.
- [ ] Vollständiger **Regression-Pass** (Routenliste aus `App.jsx`).
- [ ] Gitea #30: Abschlusskommentar mit **Screenshots** (optional) + Hinweis auf diesen Plan.
- [ ] `REVIEW_OPEN_ISSUES_2026-04-04.md` oder Nachfolger: #30 auf DONE setzen, wenn aus eurer Sicht erledigt.
### Abnahmekriterien (Gesamt gem. #30 / Spec)
- [ ] Desktop **≥1024px**: Sidebar links, Bottom-Nav aus.
- [ ] Mobile **<1024px**: Bottom-Nav unten, Sidebar aus.
- [ ] Aktive Route in beiden Navs korrekt.
- [ ] Dashboard / Verlauf / Analyse / Erfassung / Admin gemäß Spec umgesetzt.
- [ ] Resize ohne JavaScript-Zwang stabil (CSS-first).
- [ ] **PWA iPhone** weiterhin funktionsfähig (Kernflows).
### Tests (P8 Smoke-Checkliste)
| Route | T-H2 | T-H1 |
|-------|------|------|
| `/` | ☐ | ☐ |
| `/capture` | ☐ | ☐ |
| `/history` | ☐ | ☐ |
| `/analysis` | ☐ | ☐ |
| `/settings` | ☐ | ☐ |
| 1× Admin (falls verfügbar) | ☐ | ☐ |
| T-H4 PWA kurz | ☐ | n/a |
---
## Parallelität (Canvas / Workflow)
- Während **P1P2** möglichst **keine** parallelenden Änderungen an `App.jsx` / globalem `app.css` ohne Absprache.
- Neue **Workflow-/Canvas-Routen**: nur innerhalb von `<main>`; Sidebar- und Breakpoint-**Kontrakt** einhalten (`margin-left`, keine zweite globale Spalte).
---
## Planpflege
Änderungen an Phasen, Kriterien oder Status: **dieses File** anpassen und kurz „Letzte Plan-Aktualisierung“ oben datieren. Bei größeren Scope-Änderungen Gitea #30 kommentieren.

View File

@ -25,8 +25,8 @@
| 27 | Korrelationen & Insights | TEILWEISE | C-Charts + offene Data-Layer-TODOs |
| 29 | Abilities-Matrix UI | TEILWEISE | Admin/ProfileBuilder, UX offen |
| 30 | Responsive UI Sidebar | OFFEN | Weiterhin Bottom-Nav-fokussiert |
| 32 | version.py + `/api/version` | OFFEN | `version.py` ja, dedizierter Endpoint nein |
| 33 | main.py Hardcoded Version | OFFEN | FastAPI `3.0.0`, Root `v9c-dev` |
| 32 | Version-System (inkl. ehem. #33) | OFFEN | Gitea: Body/Titel 2026-04-04 aktualisiert; Runner/Build-Git bewusst später |
| 33 | — | GESCHLOSSEN | In #32 konsolidiert (superseded) |
| 34 | External Volumes Doku | PRÜFEN | Gegen Compose abgleichen |
| 35 | `subscriptions` Tabelle | PRÜFEN | Schema prüfen |
| 36 | BUG Trainingstyp ISE | PRÜFEN | Logs nötig |
@ -117,23 +117,19 @@
**Code-Stand:** Weiterhin stark **Mobile-first** (z. B. `bottom-nav` in `App.jsx`); keine ausgebaute Desktop-Sidebar wie im klassischen Admin-Dashboard.
**Umsetzungsplan:** `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` (Phasen P0P8, Abnahmekriterien & Tests, Fortschrittstabelle).
**Vorschlag:** `OFFEN`.
---
### #32 Version-System (`version.py` + `/api/version`)
### #32 Version-System (inkl. ehem. #33)
**Code-Stand:** `backend/version.py` existiert mit `APP_VERSION`. **`GET /api/version`** im Backend **nicht** gefunden (Suche nach Route); Root liefert u. a. `"version": "v9c-dev"`.
**Gitea (2026-04-04):** #33 geschlossen; Inhalt in **#32** zusammengeführt (ein Epic: `/api/version`, `version.js`, Settings, `main.py`-Konsistenz). Automatische Git-/Build-Identität über den Runner: **zurückgestellt**, geplant als **separates Issue** nach der Basis.
**Vorschlag:** `OFFEN` für #32 `/api/version` implementieren oder Issue anpassen („nur version.py ohne Endpoint“).
**Code-Stand:** `backend/version.py` vorhanden; **`GET /api/version`** fehlt; `main.py`: Root `v9c-dev`, `FastAPI(..., version="3.0.0")`.
---
### #33 main.py hardcoded Version entfernen
**Code-Stand:** `main.py`: `FastAPI(..., version="3.0.0")`; Root-JSON noch `v9c-dev`.
**Vorschlag:** `OFFEN` auf `version.py` vereinheitlichen (inkl. FastAPI-`version`-Feld und Health-Payload).
**Vorschlag:** `OFFEN` Umsetzung laut aktualisiertem Gitea-Issue #32.
---
@ -254,7 +250,7 @@
- [ ] Duplikate schließen und verlinken (#42/#43, #54/#55, #56#58).
- [ ] „DONE?“-Issues manuell testen (`#25`, `#38`, `#40`).
- [ ] `#37` umsetzen oder Kommentar „noch offen“ bestätigen.
- [ ] `#32``#33` Versionierung planen (ein gemeinsames Mini-Epic).
- [x] `#32``#33` in Gitea zusammengeführt (#33 geschlossen); Umsetzung weiter über #32.
- [ ] Kommentare aus diesem Dokument kopieren/anpassen.
- [ ] Optional: Labels in Gitea setzen (`duplicate`, `blocked`, `needs-retest`).

View File

@ -15,6 +15,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"reactflow": "^11.11.4",
"recharts": "^2.12.7"
},
"devDependencies": {
@ -2059,6 +2060,108 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@ -2554,24 +2657,159 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@ -2587,6 +2825,24 @@
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
@ -2596,6 +2852,18 @@
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@ -2611,12 +2879,37 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2624,6 +2917,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@ -3024,6 +3323,12 @@
"node": ">=10.0.0"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -3145,6 +3450,28 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@ -3200,6 +3527,16 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@ -3245,6 +3582,41 @@
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -5161,6 +5533,24 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
@ -6187,6 +6577,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
@ -6761,6 +7160,34 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@ -9,14 +9,15 @@
"preview": "vite preview"
},
"dependencies": {
"dayjs": "^1.11.11",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.383.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"dayjs": "^1.11.11",
"lucide-react": "^0.383.0"
"reactflow": "^11.11.4",
"recharts": "^2.12.7"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",

View File

@ -39,6 +39,7 @@ import RestDaysPage from './pages/RestDaysPage'
import VitalsPage from './pages/VitalsPage'
import GoalsPage from './pages/GoalsPage'
import CustomGoalsPage from './pages/CustomGoalsPage'
import WorkflowEditorPage from './pages/WorkflowEditorPage'
import './app.css'
function Nav() {
@ -194,6 +195,7 @@ function AppShell() {
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/>
<Route path="/admin/goal-types" element={<AdminGoalTypesPage/>}/>
<Route path="/admin/focus-areas" element={<AdminFocusAreasPage/>}/>
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes>
</main>

View File

@ -0,0 +1,70 @@
import ReactFlow, { Background, Controls, MiniMap } from 'reactflow'
import 'reactflow/dist/style.css'
/**
* WorkflowCanvas - React Flow Wrapper Component
*
* Kapselt React Flow Setup (Background, Controls, MiniMap).
* Separation of Concerns: Canvas-Logik getrennt von Editor-Orchestrierung.
*
* Props:
* - nodes: Array of React Flow nodes
* - edges: Array of React Flow edges
* - nodeTypes: Object mapping node type to component
* - onNodesChange: Handler for node changes (drag, delete, etc.)
* - onEdgesChange: Handler for edge changes
* - onConnect: Handler for new edge connections
* - onNodeClick: Handler for node selection
*/
export function WorkflowCanvas({
nodes,
edges,
nodeTypes,
onNodesChange,
onEdgesChange,
onConnect,
onNodeClick
}) {
return (
<div style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
className="workflow-canvas"
minZoom={0.2}
maxZoom={2}
defaultEdgeOptions={{
animated: false,
style: { strokeWidth: 2 }
}}
>
<Background
variant="dots"
gap={16}
size={1}
color="var(--border)"
/>
<Controls
showZoom={true}
showFitView={true}
showInteractive={true}
/>
<MiniMap
nodeStrokeWidth={3}
zoomable
pannable
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)'
}}
/>
</ReactFlow>
</div>
)
}

View File

@ -0,0 +1,51 @@
import { Handle, Position } from 'reactflow'
/**
* AnalysisNode - KI-Prompt Knoten
*
* Properties:
* - data.label: Node-Label
* - data.prompt_id: ID des referenzierten Basis-Prompts
* - data.prompt_name: Name des Prompts (optional, für Display)
* - data.questions: Array von Question Augmentations
* - selected: Boolean
*/
export function AnalysisNode({ data, selected }) {
const hasQuestions = data.questions?.length > 0
const promptName = data.prompt_name || (data.prompt_id ? `Prompt #${data.prompt_id}` : 'Kein Prompt')
const questionCount = data.questions?.length || 0
return (
<div className={`workflow-node analysis-node ${selected ? 'selected' : ''}`}>
<div className="node-header">
<div className="node-icon">🤖</div>
<div className="node-label">{data.label || 'Analyse'}</div>
</div>
<div className="node-body">
<div className="prompt-name" title={promptName}>
{promptName}
</div>
{hasQuestions && (
<div className="questions-indicator">
📋 {questionCount} {questionCount === 1 ? 'Frage' : 'Fragen'}
</div>
)}
</div>
<Handle
type="target"
position={Position.Top}
id="in"
style={{ background: 'var(--accent)' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="out"
style={{ background: 'var(--accent)' }}
/>
</div>
)
}

View File

@ -0,0 +1,25 @@
import { Handle, Position } from 'reactflow'
/**
* EndNode - Workflow Austritt
*
* Properties:
* - data.label: Node-Label (default: "Ende")
* - selected: Boolean (Node ist ausgewählt)
*/
export function EndNode({ data, selected }) {
return (
<div className={`workflow-node end-node ${selected ? 'selected' : ''}`}>
<div className="node-icon">🏁</div>
<div className="node-label">{data.label || 'Ende'}</div>
{/* Nur Target Handle (kein Source, da Endpunkt) */}
<Handle
type="target"
position={Position.Top}
id="in"
style={{ background: 'var(--danger)' }}
/>
</div>
)
}

View File

@ -0,0 +1,74 @@
import { Handle, Position } from 'reactflow'
/**
* JoinNode - Pfad-Konsolidierung
*
* Properties:
* - data.label: Node-Label
* - data.join_strategy: 'wait_all' | 'wait_any' | 'best_effort'
* - data.skip_handling: 'ignore_skipped' | 'use_placeholder' | 'require_minimum'
* - selected: Boolean
*/
export function JoinNode({ data, selected }) {
const strategy = data.join_strategy || 'wait_all'
const skipHandling = data.skip_handling || 'ignore_skipped'
// Strategy Display Names
const strategyLabels = {
'wait_all': 'Alle warten',
'wait_any': 'Beliebig',
'best_effort': 'Best Effort'
}
const skipLabels = {
'ignore_skipped': 'Ignorieren',
'use_placeholder': 'Platzhalter',
'require_minimum': 'Minimum'
}
return (
<div className={`workflow-node join-node ${selected ? 'selected' : ''}`}>
<div className="node-header">
<div className="node-icon">🔀</div>
<div className="node-label">{data.label || 'Join'}</div>
</div>
<div className="node-body">
<div className="strategy">
<strong>Strategie:</strong> {strategyLabels[strategy] || strategy}
</div>
<div className="skip-handling">
<strong>Skip:</strong> {skipLabels[skipHandling] || skipHandling}
</div>
</div>
{/* Mehrere Target Handles für eingehende Pfade */}
<Handle
type="target"
position={Position.Top}
id="path_1"
style={{ left: '25%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_2"
style={{ left: '50%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_3"
style={{ left: '75%', background: '#17A2B8' }}
/>
{/* Ein Source Handle für konsolidierten Ausgang */}
<Handle
type="source"
position={Position.Bottom}
id="out"
style={{ background: '#17A2B8' }}
/>
</div>
)
}

View File

@ -0,0 +1,68 @@
import { Handle, Position } from 'reactflow'
/**
* LogicNode - Bedingungs-Knoten (If-Then-Else)
*
* Properties:
* - data.label: Node-Label
* - data.condition: Logic Expression Object
* - selected: Boolean
*/
export function LogicNode({ data, selected }) {
const hasCondition = !!data.condition && !!data.condition.operator
// Condition Summary (vereinfacht für Display)
const getConditionSummary = () => {
if (!hasCondition) return 'Keine Bedingung'
const op = data.condition.operator?.toUpperCase()
const operandCount = data.condition.operands?.length || 0
if (op === 'AND' || op === 'OR' || op === 'NOT') {
return `${op} (${operandCount} Bedingungen)`
}
// Simple condition (ref, operator, value)
if (data.condition.ref) {
return `${data.condition.ref} ${data.condition.operator} ...`
}
return 'Bedingung definiert'
}
return (
<div className={`workflow-node logic-node ${selected ? 'selected' : ''}`}>
<div className="node-header">
<div className="node-icon"></div>
<div className="node-label">{data.label || 'Logik'}</div>
</div>
<div className="node-body">
<div className={`condition-summary ${hasCondition ? 'has-condition' : 'no-condition'}`}>
{getConditionSummary()}
</div>
</div>
<Handle
type="target"
position={Position.Top}
id="in"
style={{ background: '#FFC107' }}
/>
{/* Zwei Source Handles für True/False Pfade */}
<Handle
type="source"
position={Position.Bottom}
id="true"
style={{ left: '33%', background: '#4CAF50' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="false"
style={{ left: '66%', background: '#F44336' }}
/>
</div>
)
}

View File

@ -0,0 +1,25 @@
import { Handle, Position } from 'reactflow'
/**
* StartNode - Workflow Einstiegspunkt
*
* Properties:
* - data.label: Node-Label (default: "Start")
* - selected: Boolean (Node ist ausgewählt)
*/
export function StartNode({ data, selected }) {
return (
<div className={`workflow-node start-node ${selected ? 'selected' : ''}`}>
<div className="node-icon">🚀</div>
<div className="node-label">{data.label || 'Start'}</div>
{/* Nur Source Handle (kein Target, da Einstiegspunkt) */}
<Handle
type="source"
position={Position.Bottom}
id="out"
style={{ background: 'var(--accent)' }}
/>
</div>
)
}

View File

@ -0,0 +1,70 @@
/**
* FallbackConfig - Fallback-Strategie Konfiguration
*
* Props:
* - node: React Flow Node object
* - edges: Array of React Flow edges
* - onChange: (nodeId, updates) => void
*/
export function FallbackConfig({ node, edges, onChange }) {
const fallbackStrategy = node.data.fallback_strategy || 'conservative_skip'
const fallbackEdge = node.data.fallback_edge || null
// Outgoing Edges von diesem Node
const outgoingEdges = edges.filter(e => e.source === node.id)
const handleStrategyChange = (e) => {
const strategy = e.target.value
onChange(node.id, { fallback_strategy: strategy })
// Reset fallback_edge wenn strategy geändert wird
if (strategy === 'conservative_skip' || strategy === 'document_only') {
onChange(node.id, { fallback_edge: null })
}
}
const handleEdgeChange = (e) => {
onChange(node.id, { fallback_edge: e.target.value || null })
}
return (
<div className="config-section">
<h3>Fallback-Strategie</h3>
<label>Strategie bei unklaren Signalen</label>
<select value={fallbackStrategy} onChange={handleStrategyChange}>
<option value="conservative_skip">Konservativ überspringen</option>
<option value="default_path">Standardpfad ausführen</option>
<option value="uncertainty_path">Unsicherheits-Pfad</option>
<option value="document_only">Nur dokumentieren</option>
</select>
<div className="help-text">
{fallbackStrategy === 'conservative_skip' && 'Bei Unklarheit: Pfad nicht routen.'}
{fallbackStrategy === 'default_path' && 'Bei Unklarheit: Definierter Standardpfad wird ausgeführt.'}
{fallbackStrategy === 'uncertainty_path' && 'Bei Unklarheit: Expliziter Klärungspfad.'}
{fallbackStrategy === 'document_only' && 'Unklarheit dokumentieren, aber kein Routing.'}
</div>
{(fallbackStrategy === 'default_path' || fallbackStrategy === 'uncertainty_path') && (
<>
<label style={{ marginTop: '16px' }}>Ziel-Edge</label>
<select value={fallbackEdge || ''} onChange={handleEdgeChange}>
<option value="">-- Kante wählen --</option>
{outgoingEdges.map(e => (
<option key={e.id} value={e.id}>
{e.data?.label || `Edge → ${e.target}`}
</option>
))}
</select>
{outgoingEdges.length === 0 && (
<div className="help-text" style={{ color: 'var(--danger)' }}>
Keine ausgehenden Kanten gefunden. Bitte verbinden Sie diesen Node zuerst.
</div>
)}
</>
)}
</div>
)
}

View File

@ -0,0 +1,67 @@
/**
* JoinConfig - Konfiguration für Join Nodes
*
* Props:
* - node: React Flow Node object (type='join')
* - onChange: (nodeId, updates) => void
*/
export function JoinConfig({ node, onChange }) {
const joinStrategy = node.data.join_strategy || 'wait_all'
const skipHandling = node.data.skip_handling || 'ignore_skipped'
const minPaths = node.data.min_paths || 2
const handleStrategyChange = (e) => {
onChange(node.id, { join_strategy: e.target.value })
}
const handleSkipChange = (e) => {
onChange(node.id, { skip_handling: e.target.value })
}
const handleMinPathsChange = (e) => {
const value = parseInt(e.target.value) || 2
onChange(node.id, { min_paths: value })
}
return (
<div className="config-section">
<h3>Join-Konfiguration</h3>
<label>Join-Strategie</label>
<select value={joinStrategy} onChange={handleStrategyChange}>
<option value="wait_all">Alle Pfade warten (wait_all)</option>
<option value="wait_any">Mindestens ein Pfad (wait_any)</option>
<option value="best_effort">Verfügbare nutzen (best_effort)</option>
</select>
<div className="help-text">
{joinStrategy === 'wait_all' && 'Wartet auf alle eingehenden Pfade. Fehler wenn einer fehlt.'}
{joinStrategy === 'wait_any' && 'Wartet auf mindestens einen Pfad. Erste verfügbare Ausführung.'}
{joinStrategy === 'best_effort' && 'Fehlertoleranz: Nutzt verfügbare Pfade, auch wenn nicht alle da sind.'}
</div>
<label style={{ marginTop: '16px' }}>Skip-Handling</label>
<select value={skipHandling} onChange={handleSkipChange}>
<option value="ignore_skipped">Übersprungene ignorieren</option>
<option value="use_placeholder">Platzhalter verwenden</option>
<option value="require_minimum">Mindestanzahl erforderlich</option>
</select>
{skipHandling === 'require_minimum' && (
<>
<label style={{ marginTop: '12px' }}>Mindestanzahl Pfade</label>
<input
type="number"
min="1"
value={minPaths}
onChange={handleMinPathsChange}
/>
</>
)}
<div className="help-text" style={{ marginTop: '8px', fontSize: '11px', color: 'var(--text3)' }}>
💡 Phase 4: Path Consolidation
</div>
</div>
)
}

View File

@ -0,0 +1,353 @@
import { useState, useEffect, useMemo } from 'react'
/**
* LogicExpressionEditor - Visueller Baukasten für Logik-Bedingungen
*
* KEIN JSON-Editor! Visuelles Drag & Drop Interface.
*
* Props:
* - node: React Flow Node object (type='logic')
* - nodes: Array of all nodes (for signal reference lookup)
* - edges: Array of all edges (for topological sort)
* - onChange: (nodeId, updates) => void
*/
export function LogicExpressionEditor({ node, nodes, edges, onChange }) {
const [expression, setExpression] = useState(node.data.condition || {
operator: 'and',
operands: []
})
// Verfügbare Signale aus vorangegangenen Nodes
const availableSignals = useMemo(() => {
return getAvailableSignals(node.id, nodes, edges)
}, [node.id, nodes, edges])
// Sync to parent when expression changes
useEffect(() => {
onChange(node.id, { condition: expression })
}, [expression])
const handleOperatorChange = (e) => {
setExpression({ ...expression, operator: e.target.value })
}
const handleAddCondition = () => {
setExpression({
...expression,
operands: [...(expression.operands || []), {
ref: '',
operator: 'eq',
value: ''
}]
})
}
const handleAddGroup = () => {
setExpression({
...expression,
operands: [...(expression.operands || []), {
operator: 'and',
operands: []
}]
})
}
const handleOperandChange = (index, updates) => {
const updated = [...(expression.operands || [])]
updated[index] = { ...updated[index], ...updates }
setExpression({ ...expression, operands: updated })
}
const handleRemoveOperand = (index) => {
setExpression({
...expression,
operands: (expression.operands || []).filter((_, i) => i !== index)
})
}
return (
<div className="config-section">
<h3>Logik-Bedingung</h3>
{availableSignals.length === 0 && (
<div className="help-text" style={{ color: 'var(--danger)', marginBottom: '12px' }}>
Keine Signale verfügbar. Fügen Sie zuerst einen Analysis-Node VOR diesem Node hinzu.
</div>
)}
<div className="logic-root">
<label>Verknüpfung (Root-Operator)</label>
<select value={expression.operator || 'and'} onChange={handleOperatorChange}>
<option value="and">UND (alle müssen zutreffen)</option>
<option value="or">ODER (mind. eine muss zutreffen)</option>
<option value="not">NICHT (umkehren)</option>
</select>
</div>
<div className="logic-operands">
{(expression.operands || []).map((operand, idx) => (
<ConditionBlock
key={idx}
operand={operand}
availableSignals={availableSignals}
onChange={(updates) => handleOperandChange(idx, updates)}
onRemove={() => handleRemoveOperand(idx)}
/>
))}
{(!expression.operands || expression.operands.length === 0) && (
<div className="help-text" style={{ marginBottom: '12px' }}>
Keine Bedingungen definiert. Fügen Sie Bedingungen oder Gruppen hinzu.
</div>
)}
</div>
<div className="logic-actions">
<button className="btn-secondary" onClick={handleAddCondition}>
+ Bedingung
</button>
<button className="btn-secondary" onClick={handleAddGroup}>
+ Gruppe (AND/OR)
</button>
</div>
<div className="help-text" style={{ marginTop: '12px', fontSize: '11px', color: 'var(--text3)' }}>
💡 Signale: {availableSignals.length} verfügbar
</div>
</div>
)
}
/**
* ConditionBlock - Einzelne Bedingung oder Gruppe (rekursiv)
*/
function ConditionBlock({ operand, availableSignals, onChange, onRemove }) {
// Verschachtelte Gruppe? (AND/OR/NOT)
const isGroup = operand.operator === 'and' || operand.operator === 'or' || operand.operator === 'not'
if (isGroup) {
return (
<div className="condition-group">
<div className="group-header">
<select
value={operand.operator}
onChange={(e) => onChange({ operator: e.target.value })}
style={{ flex: 1 }}
>
<option value="and">UND</option>
<option value="or">ODER</option>
<option value="not">NICHT</option>
</select>
<button className="btn-icon" onClick={onRemove}>🗑</button>
</div>
{/* Rekursiv: Nested operands */}
<div style={{ paddingLeft: '12px', borderLeft: '3px solid var(--accent)' }}>
{(operand.operands || []).map((subOp, idx) => (
<ConditionBlock
key={idx}
operand={subOp}
availableSignals={availableSignals}
onChange={(updates) => {
const updated = [...(operand.operands || [])]
updated[idx] = { ...updated[idx], ...updates }
onChange({ operands: updated })
}}
onRemove={() => {
onChange({ operands: (operand.operands || []).filter((_, i) => i !== idx) })
}}
/>
))}
<button
className="btn-secondary"
onClick={() => {
onChange({
operands: [...(operand.operands || []), { ref: '', operator: 'eq', value: '' }]
})
}}
style={{ marginTop: '8px', fontSize: '12px' }}
>
+ Bedingung
</button>
</div>
</div>
)
}
// Einfache Bedingung
const selectedSignal = availableSignals.find(s => s.ref === operand.ref)
return (
<div className="condition-simple">
{/* Signal-Referenz (Dropdown) */}
<select
value={operand.ref || ''}
onChange={(e) => onChange({ ref: e.target.value })}
className="signal-select"
style={{ flex: 2 }}
>
<option value="">-- Signal wählen --</option>
{availableSignals.map(sig => (
<option key={sig.ref} value={sig.ref}>
{sig.label}
</option>
))}
</select>
{/* Operator */}
<select
value={operand.operator || 'eq'}
onChange={(e) => onChange({ operator: e.target.value })}
className="operator-select"
style={{ flex: 1 }}
>
<option value="eq">==</option>
<option value="neq">!=</option>
<option value="in">IN</option>
<option value="not_in">NOT IN</option>
<option value="gt">&gt;</option>
<option value="lt">&lt;</option>
<option value="gte">&gt;=</option>
<option value="lte">&lt;=</option>
<option value="contains">CONTAINS</option>
</select>
{/* Wert (abhängig von Operator) */}
{(operand.operator === 'in' || operand.operator === 'not_in') ? (
<MultiSelect
options={selectedSignal?.spectrum || []}
value={operand.value || []}
onChange={(val) => onChange({ value: val })}
placeholder="Werte wählen"
/>
) : (
<input
type="text"
value={operand.value || ''}
onChange={(e) => onChange({ value: e.target.value })}
placeholder="Wert"
className="value-input"
style={{ flex: 1 }}
/>
)}
<button className="btn-icon" onClick={onRemove}>🗑</button>
</div>
)
}
/**
* MultiSelect - Multi-Auswahl für IN/NOT_IN Operatoren
*/
function MultiSelect({ options, value, onChange, placeholder }) {
const [isOpen, setIsOpen] = useState(false)
const selected = Array.isArray(value) ? value : []
const handleToggle = (option) => {
if (selected.includes(option)) {
onChange(selected.filter(v => v !== option))
} else {
onChange([...selected, option])
}
}
return (
<div className="multi-select" style={{ flex: 1, position: 'relative' }}>
<div
className="multi-select-display"
onClick={() => setIsOpen(!isOpen)}
style={{
padding: '6px',
border: '1px solid var(--border)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
background: 'var(--bg)'
}}
>
{selected.length > 0 ? selected.join(', ') : placeholder}
</div>
{isOpen && (
<div
className="multi-select-dropdown"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '4px',
marginTop: '4px',
maxHeight: '200px',
overflowY: 'auto',
zIndex: 1000
}}
>
{options.map((option, i) => (
<div
key={i}
onClick={() => handleToggle(option)}
style={{
padding: '8px',
cursor: 'pointer',
fontSize: '12px',
background: selected.includes(option) ? 'var(--accent)' : 'transparent',
color: selected.includes(option) ? 'white' : 'var(--text1)'
}}
>
{selected.includes(option) && '✓ '}
{option}
</div>
))}
{options.length === 0 && (
<div style={{ padding: '8px', fontSize: '12px', color: 'var(--text3)' }}>
Keine Optionen verfügbar
</div>
)}
</div>
)}
</div>
)
}
/**
* Helper: Verfügbare Signale aus vorangegangenen Nodes extrahieren
*/
function getAvailableSignals(nodeId, nodes, edges) {
// Topologische Sortierung: Alle Nodes VOR diesem Node
const predecessors = new Set()
function collectPredecessors(currentId) {
const incoming = edges.filter(e => e.target === currentId)
for (const edge of incoming) {
if (!predecessors.has(edge.source)) {
predecessors.add(edge.source)
collectPredecessors(edge.source)
}
}
}
collectPredecessors(nodeId)
// Signale extrahieren (nur von ANALYSIS Nodes)
const signals = []
for (const predId of predecessors) {
const predNode = nodes.find(n => n.id === predId)
if (predNode && predNode.type === 'analysis') {
const questions = predNode.data.questions || []
for (const q of questions) {
signals.push({
ref: `${predId}.${q.id}`,
label: `${predNode.data.label}${q.question || q.id}`,
spectrum: q.answer_spectrum || []
})
}
}
}
return signals
}

View File

@ -0,0 +1,218 @@
import { useState, useEffect } from 'react'
/**
* QuestionAugmentationPanel - Fragenergänzungs-Konfiguration
*
* Props:
* - node: React Flow Node object (type='analysis')
* - onChange: (nodeId, updates) => void
*/
export function QuestionAugmentationPanel({ node, onChange }) {
const [questions, setQuestions] = useState(node.data.questions || [])
// Sync to parent when questions change
useEffect(() => {
onChange(node.id, { questions })
}, [questions])
const handleAddQuestion = () => {
const newQuestion = {
id: `q${Date.now()}`,
type: 'relevanz',
question: '',
answer_spectrum: []
}
setQuestions([...questions, newQuestion])
}
const handleRemoveQuestion = (index) => {
setQuestions(questions.filter((_, i) => i !== index))
}
const handleQuestionChange = (index, field, value) => {
const updated = [...questions]
updated[index] = { ...updated[index], [field]: value }
setQuestions(updated)
}
return (
<div className="config-section">
<h3>Fragenergänzung</h3>
{questions.length === 0 && (
<div className="help-text" style={{ marginBottom: '12px' }}>
Keine Fragen definiert. Fügen Sie Fragen hinzu, um Signale für Logik-Knoten zu erzeugen.
</div>
)}
{questions.map((q, idx) => (
<QuestionEditor
key={q.id}
question={q}
index={idx}
onChange={(field, value) => handleQuestionChange(idx, field, value)}
onRemove={() => handleRemoveQuestion(idx)}
/>
))}
<button className="btn-secondary btn-full" onClick={handleAddQuestion}>
+ Frage hinzufügen
</button>
</div>
)
}
/**
* QuestionEditor - Einzelne Frage editieren
*/
function QuestionEditor({ question, index, onChange, onRemove }) {
const [spectrumInput, setSpectrumInput] = useState('')
const handleAddAnswer = () => {
if (!spectrumInput.trim()) return
const newSpectrum = [...(question.answer_spectrum || []), spectrumInput.trim()]
onChange('answer_spectrum', newSpectrum)
setSpectrumInput('')
}
const handleRemoveAnswer = (answer) => {
const newSpectrum = question.answer_spectrum.filter(a => a !== answer)
onChange('answer_spectrum', newSpectrum)
}
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddAnswer()
}
}
return (
<div className="question-editor">
<div className="question-header">
<span>Frage {index + 1}</span>
<button className="btn-icon" onClick={onRemove} title="Frage löschen">
🗑
</button>
</div>
<label>Frage-ID</label>
<input
type="text"
value={question.id || ''}
onChange={(e) => onChange('id', e.target.value)}
placeholder="z.B. relevanz"
/>
<label>Fragetyp</label>
<select
value={question.type || 'relevanz'}
onChange={(e) => onChange('type', e.target.value)}
>
<option value="relevanz">Relevanz</option>
<option value="prioritaet">Priorität</option>
<option value="selektion">Selektion</option>
<option value="ausschluss">Ausschluss</option>
<option value="eskalation">Eskalation</option>
<option value="unsicherheit">Unsicherheit</option>
</select>
<label>Fragetext</label>
<textarea
value={question.question || ''}
onChange={(e) => onChange('question', e.target.value)}
placeholder="z.B. Ist diese Analyse relevant für den Nutzer?"
rows={3}
/>
<label>Antwortmöglichkeiten (mind. 2)</label>
<div className="tag-input-container">
<div className="tags">
{(question.answer_spectrum || []).map((answer, i) => (
<span key={i} className="tag">
{answer}
<button
onClick={() => handleRemoveAnswer(answer)}
className="tag-remove"
title="Entfernen"
>
×
</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type="text"
value={spectrumInput}
onChange={(e) => setSpectrumInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="z.B. ja, nein, unklar"
style={{ flex: 1 }}
/>
<button className="btn-secondary" onClick={handleAddAnswer}>
+
</button>
</div>
</div>
{question.answer_spectrum && question.answer_spectrum.length < 2 && (
<div className="help-text" style={{ color: 'var(--danger)' }}>
Mindestens 2 Antworten erforderlich
</div>
)}
</div>
)
}
// Zusätzliches CSS für Tags (inline styles)
const tagStyles = `
.tag-input-container {
margin-bottom: 8px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--accent);
color: white;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
margin-left: 2px;
opacity: 0.8;
}
.tag-remove:hover {
opacity: 1;
}
`
// Inject styles (nur einmal)
if (typeof document !== 'undefined' && !document.getElementById('question-panel-styles')) {
const styleEl = document.createElement('style')
styleEl.id = 'question-panel-styles'
styleEl.textContent = tagStyles
document.head.appendChild(styleEl)
}

View File

@ -0,0 +1,392 @@
import { useState, useCallback, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNodesState, useEdgesState, addEdge } from 'reactflow'
import { api } from '../utils/api'
import { validateWorkflowGraph } from '../utils/workflowValidation'
import { serializeToWorkflowGraph, deserializeFromWorkflowGraph } from '../utils/workflowSerializer'
import { WorkflowCanvas } from '../components/workflow/WorkflowCanvas'
import { StartNode } from '../components/workflow/nodes/StartNode'
import { EndNode } from '../components/workflow/nodes/EndNode'
import { AnalysisNode } from '../components/workflow/nodes/AnalysisNode'
import { LogicNode } from '../components/workflow/nodes/LogicNode'
import { JoinNode } from '../components/workflow/nodes/JoinNode'
import { QuestionAugmentationPanel } from '../components/workflow/panels/QuestionAugmentationPanel'
import { LogicExpressionEditor } from '../components/workflow/panels/LogicExpressionEditor'
import { FallbackConfig } from '../components/workflow/panels/FallbackConfig'
import { JoinConfig } from '../components/workflow/panels/JoinConfig'
import '../styles/workflowEditor.css'
// Node-Type Mapping
const nodeTypes = {
start: StartNode,
end: EndNode,
analysis: AnalysisNode,
logic: LogicNode,
join: JoinNode
}
let nodeIdCounter = 1
export default function WorkflowEditorPage() {
const navigate = useNavigate()
const { id } = useParams() // prompt_id wenn vorhanden
// State
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedNode, setSelectedNode] = useState(null)
const [currentPrompt, setCurrentPrompt] = useState(null)
const [workflowName, setWorkflowName] = useState('Neuer Workflow')
const [workflowDescription, setWorkflowDescription] = useState('')
const [validationErrors, setValidationErrors] = useState([])
const [validationWarnings, setValidationWarnings] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// Load workflow wenn ID vorhanden
useEffect(() => {
if (id && id !== 'new') {
loadWorkflow(parseInt(id))
}
}, [id])
// Auto-Validation
useEffect(() => {
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
setValidationErrors(errors)
setValidationWarnings(warnings)
}, [nodes, edges])
// Handlers
const onConnect = useCallback(
(params) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
)
const onNodeClick = useCallback((event, node) => {
setSelectedNode(node)
}, [])
const handleAddNode = (nodeType) => {
const newNode = {
id: `node_${nodeIdCounter++}`,
type: nodeType,
position: { x: 250, y: 100 + nodes.length * 100 },
data: {
label: `${nodeType.charAt(0).toUpperCase() + nodeType.slice(1)} ${nodeIdCounter - 1}`
}
}
setNodes((nds) => [...nds, newNode])
}
const handleNodeUpdate = (nodeId, updates) => {
setNodes((nds) =>
nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...updates } } : n))
)
}
const handleDeleteNode = () => {
if (!selectedNode) return
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id))
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id))
setSelectedNode(null)
}
const handleSave = async () => {
try {
setLoading(true)
setError(null)
// Validierung
const { errors, isValid } = validateWorkflowGraph(nodes, edges)
if (!isValid) {
setError(`Validierung fehlgeschlagen: ${errors.length} Fehler gefunden`)
return
}
// Serialisieren
const graph_data = serializeToWorkflowGraph(nodes, edges, {
created_at: currentPrompt?.created_at,
version: '1.0'
})
if (currentPrompt) {
// Update existing
await api.updateUnifiedPrompt(currentPrompt.id, {
type: 'workflow',
name: workflowName,
description: workflowDescription,
graph_data
})
alert('Workflow gespeichert!')
} else {
// Create new
const result = await api.createUnifiedPrompt({
type: 'workflow',
name: workflowName,
description: workflowDescription,
graph_data
})
setCurrentPrompt({ id: result.id, name: workflowName })
navigate(`/workflow-editor/${result.id}`)
alert('Workflow erstellt!')
}
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
const loadWorkflow = async (promptId) => {
try {
setLoading(true)
setError(null)
const prompt = await api.getPrompt(promptId)
if (prompt.type !== 'workflow') {
throw new Error('Nicht ein Workflow')
}
// Deserialisieren
const { nodes: loadedNodes, edges: loadedEdges } = deserializeFromWorkflowGraph(prompt.graph_data)
setNodes(loadedNodes)
setEdges(loadedEdges)
setCurrentPrompt(prompt)
setWorkflowName(prompt.name)
setWorkflowDescription(prompt.description || '')
// nodeIdCounter aktualisieren
const maxId = Math.max(
...loadedNodes.map((n) => parseInt(n.id.replace('node_', '')) || 0),
0
)
nodeIdCounter = maxId + 1
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
const handleValidate = () => {
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
setValidationErrors(errors)
setValidationWarnings(warnings)
if (errors.length === 0) {
alert(`✅ Workflow ist valide!\n\n${warnings.length} Warnungen`)
} else {
alert(`❌ Validierung fehlgeschlagen!\n\n${errors.length} Fehler, ${warnings.length} Warnungen`)
}
}
const handleNew = () => {
if (confirm('Neuen Workflow erstellen? Ungespeicherte Änderungen gehen verloren.')) {
setNodes([])
setEdges([])
setCurrentPrompt(null)
setWorkflowName('Neuer Workflow')
setWorkflowDescription('')
setSelectedNode(null)
navigate('/workflow-editor/new')
}
}
const handleDelete = async () => {
if (!currentPrompt) return
if (!confirm(`Workflow "${workflowName}" wirklich löschen?`)) return
try {
setLoading(true)
await api.deletePrompt(currentPrompt.id)
alert('Workflow gelöscht')
navigate('/admin/prompts')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
// Render
return (
<div className="workflow-editor">
{/* Toolbar */}
<div className="workflow-toolbar">
<button className="btn-secondary" onClick={() => navigate('/admin/prompts')}>
Zurück
</button>
<input
type="text"
value={workflowName}
onChange={(e) => setWorkflowName(e.target.value)}
placeholder="Workflow-Name"
style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }}
/>
<button className="btn-secondary" onClick={handleNew}>
Neu
</button>
<button className="btn-secondary" onClick={handleValidate}>
Validieren
</button>
<button className="btn-primary" onClick={handleSave} disabled={loading}>
{loading ? 'Speichern...' : 'Speichern'}
</button>
{currentPrompt && (
<button className="btn-secondary" onClick={handleDelete} disabled={loading}>
Löschen
</button>
)}
</div>
{error && (
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white' }}>
{error}
</div>
)}
{/* Main Content */}
<div className="workflow-content">
{/* Sidebar */}
<div className="workflow-sidebar">
<div className="sidebar-section">
<h3>Workflow-Knoten</h3>
<div className="node-palette">
<button className="node-palette-button" onClick={() => handleAddNode('start')}>
<span className="icon">🚀</span> Start
</button>
<button className="node-palette-button" onClick={() => handleAddNode('analysis')}>
<span className="icon">🤖</span> Analyse
</button>
<button className="node-palette-button" onClick={() => handleAddNode('logic')}>
<span className="icon"></span> Logik
</button>
<button className="node-palette-button" onClick={() => handleAddNode('join')}>
<span className="icon">🔀</span> Join
</button>
<button className="node-palette-button" onClick={() => handleAddNode('end')}>
<span className="icon">🏁</span> Ende
</button>
</div>
</div>
{selectedNode && (
<div className="sidebar-section">
<h3>Aktionen</h3>
<button className="btn-secondary btn-full" onClick={handleDeleteNode}>
🗑 Node löschen
</button>
</div>
)}
<div className="sidebar-section">
<h3>Info</h3>
<div style={{ fontSize: '12px', color: 'var(--text3)' }}>
<div>Nodes: {nodes.length}</div>
<div>Edges: {edges.length}</div>
<div>Errors: {validationErrors.length}</div>
<div>Warnings: {validationWarnings.length}</div>
</div>
</div>
</div>
{/* Canvas */}
<div className="workflow-canvas-container">
<WorkflowCanvas
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
/>
</div>
{/* Config Panel */}
{selectedNode && (
<div className="workflow-config-panel">
<h2 style={{ margin: '0 0 16px 0' }}>{selectedNode.data.label}</h2>
{/* Basis-Konfiguration */}
<div className="config-section">
<label>Label</label>
<input
type="text"
value={selectedNode.data.label || ''}
onChange={(e) => handleNodeUpdate(selectedNode.id, { label: e.target.value })}
/>
</div>
{/* Type-spezifische Konfiguration */}
{selectedNode.type === 'analysis' && (
<>
<QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
<FallbackConfig node={selectedNode} edges={edges} onChange={handleNodeUpdate} />
</>
)}
{selectedNode.type === 'logic' && (
<>
<LogicExpressionEditor
node={selectedNode}
nodes={nodes}
edges={edges}
onChange={handleNodeUpdate}
/>
<FallbackConfig node={selectedNode} edges={edges} onChange={handleNodeUpdate} />
</>
)}
{selectedNode.type === 'join' && (
<JoinConfig node={selectedNode} onChange={handleNodeUpdate} />
)}
</div>
)}
</div>
{/* Validation Panel */}
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
<div className="validation-panel">
{validationErrors.map((err, i) => (
<div key={i} className="validation-error" onClick={() => {
if (err.nodeId) {
const node = nodes.find(n => n.id === err.nodeId)
if (node) setSelectedNode(node)
}
}}>
{err.message}
</div>
))}
{validationWarnings.map((warn, i) => (
<div key={i} className="validation-warning" onClick={() => {
if (warn.nodeId) {
const node = nodes.find(n => n.id === warn.nodeId)
if (node) setSelectedNode(node)
}
}}>
{warn.message}
</div>
))}
{validationErrors.length === 0 && validationWarnings.length > 0 && (
<div className="validation-success">
Workflow ist valide ({validationWarnings.length} Warnungen)
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,471 @@
/* Workflow Editor Styles (Phase 5) */
/* ── Editor Layout ────────────────────────────────────────────────────────── */
.workflow-editor {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
background: var(--bg);
}
.workflow-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.workflow-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Sidebar (Node Palette) ─────────────────────────────────────────────── */
.workflow-sidebar {
width: 250px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: var(--text2);
text-transform: uppercase;
}
.node-palette {
display: flex;
flex-direction: column;
gap: 8px;
}
.node-palette-button {
padding: 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text1);
transition: all 0.2s;
}
.node-palette-button:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
transform: translateY(-1px);
}
.node-palette-button .icon {
font-size: 20px;
}
/* ── Canvas ──────────────────────────────────────────────────────────────── */
.workflow-canvas-container {
flex: 1;
position: relative;
background: var(--bg);
}
/* React Flow Overrides */
.react-flow {
background: var(--bg);
}
.react-flow__node {
border-radius: 8px;
}
.react-flow__node.selected {
box-shadow: 0 0 0 3px rgba(29, 158, 117, 0.3);
}
.react-flow__edge-path {
stroke: var(--text3);
stroke-width: 2;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: var(--accent);
stroke-width: 3;
}
.react-flow__handle {
width: 10px;
height: 10px;
border: 2px solid white;
}
.react-flow__handle-connecting {
background: var(--accent) !important;
}
.react-flow__handle-valid {
background: #4CAF50 !important;
}
/* ── Custom Nodes ────────────────────────────────────────────────────────── */
.workflow-node {
min-width: 180px;
background: var(--surface);
border: 2px solid var(--border);
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-family: inherit;
transition: all 0.2s;
}
.workflow-node:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transform: translateY(-1px);
}
.workflow-node.selected {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(29, 158, 117, 0.2);
}
.node-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.node-icon {
font-size: 20px;
line-height: 1;
}
.node-label {
font-weight: 600;
font-size: 14px;
color: var(--text1);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-body {
font-size: 12px;
color: var(--text2);
}
/* Start Node */
.workflow-node.start-node {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #667eea;
text-align: center;
}
.workflow-node.start-node .node-label,
.workflow-node.start-node .node-body {
color: white;
}
/* End Node */
.workflow-node.end-node {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border-color: #f093fb;
text-align: center;
}
.workflow-node.end-node .node-label,
.workflow-node.end-node .node-body {
color: white;
}
/* Analysis Node */
.workflow-node.analysis-node {
background: var(--surface2);
border-color: var(--accent);
}
.workflow-node.analysis-node .prompt-name {
font-weight: 500;
color: var(--text1);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-node.analysis-node .questions-indicator {
font-size: 11px;
color: var(--accent);
padding: 4px 8px;
background: rgba(29, 158, 117, 0.1);
border-radius: 4px;
display: inline-block;
margin-top: 4px;
}
/* Logic Node */
.workflow-node.logic-node {
background: #FFF3CD;
border-color: #FFC107;
}
.workflow-node.logic-node .condition-summary {
padding: 6px 8px;
background: rgba(255, 193, 7, 0.2);
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.workflow-node.logic-node .condition-summary.has-condition {
color: #856404;
}
.workflow-node.logic-node .condition-summary.no-condition {
color: #6c757d;
font-style: italic;
}
/* Join Node */
.workflow-node.join-node {
background: #D1ECF1;
border-color: #17A2B8;
}
.workflow-node.join-node .node-body {
font-size: 11px;
}
.workflow-node.join-node .strategy,
.workflow-node.join-node .skip-handling {
margin-bottom: 4px;
}
.workflow-node.join-node strong {
color: #0c5460;
}
/* ── Config Panel ────────────────────────────────────────────────────────── */
.workflow-config-panel {
width: 400px;
background: var(--surface);
border-left: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.config-section {
margin-bottom: 24px;
}
.config-section h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: var(--text1);
}
.config-section label {
display: block;
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text2);
}
.config-section input,
.config-section select,
.config-section textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 8px;
font-family: inherit;
font-size: 14px;
background: var(--bg);
color: var(--text1);
}
.config-section textarea {
resize: vertical;
min-height: 60px;
}
/* ── Question Editor ─────────────────────────────────────────────────────── */
.question-editor {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.question-header span {
font-weight: 600;
font-size: 14px;
color: var(--text1);
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
padding: 4px;
opacity: 0.7;
transition: opacity 0.2s;
}
.btn-icon:hover {
opacity: 1;
}
/* ── Logic Expression Editor ─────────────────────────────────────────────── */
.logic-root {
margin-bottom: 16px;
}
.logic-operands {
margin-top: 12px;
}
.condition-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
margin-bottom: 8px;
}
.condition-simple {
display: flex;
gap: 8px;
align-items: center;
}
.condition-simple select,
.condition-simple input {
flex: 1;
padding: 6px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
}
.condition-group {
border-left: 3px solid var(--accent);
padding-left: 12px;
margin-bottom: 8px;
}
.group-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.logic-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* ── Validation Panel ────────────────────────────────────────────────────── */
.validation-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface);
border-top: 1px solid var(--border);
padding: 12px 16px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.validation-error {
color: var(--danger);
margin-bottom: 4px;
font-size: 14px;
cursor: pointer;
}
.validation-error:hover {
text-decoration: underline;
}
.validation-warning {
color: #FFC107;
margin-bottom: 4px;
font-size: 14px;
cursor: pointer;
}
.validation-warning:hover {
text-decoration: underline;
}
.validation-success {
color: #4CAF50;
font-size: 14px;
font-weight: 600;
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 1200px) {
.workflow-sidebar {
width: 200px;
}
.workflow-config-panel {
width: 350px;
}
}
@media (max-width: 900px) {
.workflow-content {
flex-direction: column;
}
.workflow-sidebar,
.workflow-config-panel {
width: 100%;
border: none;
border-bottom: 1px solid var(--border);
}
}

View File

@ -286,6 +286,7 @@ export const api = {
// AI Prompts Management (Issue #28)
listAdminPrompts: () => req('/prompts'),
getPrompt: (id) => req(`/prompts/${id}`),
createPrompt: (d) => req('/prompts', json(d)),
updatePrompt: (id,d) => req(`/prompts/${id}`, jput(d)),
deletePrompt: (id) => req(`/prompts/${id}`, {method:'DELETE'}),

View File

@ -0,0 +1,117 @@
/**
* Workflow Serialization Utilities
*
* Konvertiert zwischen React Flow (Canvas) und Backend-Format (JSONB).
*/
/**
* Serialisiert React Flow Graph zu Backend-kompatiblem Format
*
* @param {Array} nodes - React Flow nodes
* @param {Array} edges - React Flow edges
* @param {Object} metadata - Zusätzliche Metadaten
* @returns {Object} JSONB-kompatibles Objekt für ai_prompts.graph_data
*/
export function serializeToWorkflowGraph(nodes, edges, metadata = {}) {
const workflowNodes = nodes.map(node => ({
id: node.id,
type: node.type,
label: node.data.label || node.type,
position: { x: node.position.x, y: node.position.y },
// Type-spezifische Felder
...(node.type === 'analysis' && {
prompt_id: node.data.prompt_id || null,
questions: node.data.questions || [],
fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
}),
...(node.type === 'logic' && {
condition: node.data.condition || null,
fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
}),
...(node.type === 'join' && {
join_strategy: node.data.join_strategy || 'wait_all',
skip_handling: node.data.skip_handling || 'ignore_skipped',
min_paths: node.data.min_paths || 2
})
}))
const workflowEdges = edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.data?.label || null,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null
}))
return {
nodes: workflowNodes,
edges: workflowEdges,
metadata: {
created_at: metadata.created_at || new Date().toISOString(),
updated_at: new Date().toISOString(),
version: metadata.version || '1.0'
}
}
}
/**
* Deserialisiert Backend-Format zu React Flow Graph
*
* @param {Object} jsonbData - ai_prompts.graph_data (JSONB)
* @returns {Object} { nodes, edges, metadata }
*/
export function deserializeFromWorkflowGraph(jsonbData) {
if (!jsonbData || !jsonbData.nodes || !jsonbData.edges) {
throw new Error('Invalid workflow graph data')
}
const reactFlowNodes = jsonbData.nodes.map(node => ({
id: node.id,
type: node.type,
position: { x: node.position.x, y: node.position.y },
data: {
label: node.label,
...(node.type === 'analysis' && {
prompt_id: node.prompt_id,
prompt_name: node.prompt_name || null, // Falls vom Backend mitgeliefert
questions: node.questions || [],
fallback_strategy: node.fallback_strategy || 'conservative_skip'
}),
...(node.type === 'logic' && {
condition: node.condition || null,
fallback_strategy: node.fallback_strategy || 'conservative_skip'
}),
...(node.type === 'join' && {
join_strategy: node.join_strategy || 'wait_all',
skip_handling: node.skip_handling || 'ignore_skipped',
min_paths: node.min_paths || 2
})
}
}))
const reactFlowEdges = jsonbData.edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null,
data: {
label: edge.label || null
},
type: 'default',
animated: false
}))
return {
nodes: reactFlowNodes,
edges: reactFlowEdges,
metadata: jsonbData.metadata || {}
}
}

View File

@ -0,0 +1,226 @@
/**
* Workflow Validation Utilities
*
* Validiert Workflow-Graphen (Struktur + Logik).
*/
export function validateWorkflowGraph(nodes, edges) {
const errors = []
const warnings = []
// 1. Strukturelle Validierung
validateStructure(nodes, edges, errors, warnings)
// 2. Logische Validierung
validateLogic(nodes, edges, errors, warnings)
return {
errors,
warnings,
isValid: errors.length === 0
}
}
/**
* Strukturelle Validierung (DAG, START/END, Zyklen)
*/
function validateStructure(nodes, edges, errors, warnings) {
// START Node
const startNodes = nodes.filter(n => n.type === 'start')
if (startNodes.length === 0) {
errors.push({
type: 'structure',
message: 'Kein START-Node vorhanden',
severity: 'error'
})
} else if (startNodes.length > 1) {
errors.push({
type: 'structure',
message: `${startNodes.length} START-Nodes gefunden (max. 1 erlaubt)`,
severity: 'error'
})
}
// END Node
const endNodes = nodes.filter(n => n.type === 'end')
if (endNodes.length === 0) {
errors.push({
type: 'structure',
message: 'Kein END-Node vorhanden',
severity: 'error'
})
}
// Zyklen-Erkennung
if (detectCycles(nodes, edges)) {
errors.push({
type: 'structure',
message: 'Workflow enthält Zyklen (nicht erlaubt)',
severity: 'error'
})
}
// Isolierte Nodes
nodes.forEach(node => {
if (node.type === 'start' || node.type === 'end') return
const hasIncoming = edges.some(e => e.target === node.id)
const hasOutgoing = edges.some(e => e.source === node.id)
if (!hasIncoming || !hasOutgoing) {
warnings.push({
type: 'isolation',
message: `Node "${node.data.label}" ist isoliert (keine/fehlende Verbindungen)`,
nodeId: node.id,
severity: 'warning'
})
}
})
}
/**
* Logische Validierung (Node-Konfiguration)
*/
function validateLogic(nodes, edges, errors, warnings) {
nodes.forEach(node => {
// Analysis Nodes
if (node.type === 'analysis') {
const questions = node.data.questions || []
// Prompt ausgewählt?
if (!node.data.prompt_id) {
errors.push({
type: 'config',
message: `Analysis-Node "${node.data.label}" hat keinen Prompt`,
nodeId: node.id,
severity: 'error'
})
}
// Fragen validieren
questions.forEach((q, idx) => {
if (!q.question?.trim()) {
errors.push({
type: 'config',
message: `Frage ${idx + 1} in "${node.data.label}" hat keinen Text`,
nodeId: node.id,
severity: 'error'
})
}
if (!q.answer_spectrum || q.answer_spectrum.length < 2) {
errors.push({
type: 'config',
message: `Frage ${idx + 1} in "${node.data.label}" braucht mind. 2 Antworten`,
nodeId: node.id,
severity: 'error'
})
}
})
}
// Logic Nodes
if (node.type === 'logic') {
const condition = node.data.condition
if (!condition || !condition.operator) {
errors.push({
type: 'config',
message: `Logic-Node "${node.data.label}" hat keine Bedingung`,
nodeId: node.id,
severity: 'error'
})
} else {
// Bedingung vollständig?
const incomplete = findIncompleteConditions(condition)
if (incomplete.length > 0) {
errors.push({
type: 'config',
message: `Logic-Node "${node.data.label}" hat ${incomplete.length} unvollständige Bedingung(en)`,
nodeId: node.id,
severity: 'error'
})
}
}
// Mind. 2 Outgoing Edges (true/false Pfade)
const outgoing = edges.filter(e => e.source === node.id)
if (outgoing.length < 2) {
warnings.push({
type: 'config',
message: `Logic-Node "${node.data.label}" hat nur ${outgoing.length} Ausgang (sollte mind. 2 haben)`,
nodeId: node.id,
severity: 'warning'
})
}
}
// Join Nodes
if (node.type === 'join') {
const incoming = edges.filter(e => e.target === node.id)
if (incoming.length < 2) {
warnings.push({
type: 'config',
message: `Join-Node "${node.data.label}" hat nur ${incoming.length} eingehende Kante (sollte mind. 2 haben)`,
nodeId: node.id,
severity: 'warning'
})
}
}
})
}
/**
* Zyklen-Erkennung (DFS-basiert)
*/
function detectCycles(nodes, edges) {
const visited = new Set()
const recStack = new Set()
function dfs(nodeId) {
visited.add(nodeId)
recStack.add(nodeId)
const outgoing = edges.filter(e => e.source === nodeId)
for (const edge of outgoing) {
if (!visited.has(edge.target)) {
if (dfs(edge.target)) return true
} else if (recStack.has(edge.target)) {
return true // Cycle detected
}
}
recStack.delete(nodeId)
return false
}
for (const node of nodes) {
if (!visited.has(node.id)) {
if (dfs(node.id)) return true
}
}
return false
}
/**
* Unvollständige Bedingungen finden (rekursiv)
*/
function findIncompleteConditions(condition) {
const incomplete = []
// Verschachtelte Gruppe?
if (condition.operands && Array.isArray(condition.operands)) {
for (const op of condition.operands) {
incomplete.push(...findIncompleteConditions(op))
}
} else {
// Einfache Bedingung: ref, operator, value müssen gesetzt sein
if (!condition.ref || !condition.operator || condition.value === undefined || condition.value === '') {
incomplete.push(condition)
}
}
return incomplete
}