feat: Phase 5 - Visual Workflow Editor (Option B)
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:
parent
a7058c30be
commit
dc59596f01
35
backend/migrations/016_workflows_graph_data.sql
Normal file
35
backend/migrations/016_workflows_graph_data.sql
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
264
docs/issues/PHASE_PLAN_RESPONSIVE_UI.md
Normal file
264
docs/issues/PHASE_PLAN_RESPONSIVE_UI.md
Normal 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 `240–280px` 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 P1–P7 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 **P1–P2** 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.
|
||||
|
|
@ -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 P0–P8, 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`).
|
||||
|
||||
|
|
|
|||
427
frontend/package-lock.json
generated
427
frontend/package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
70
frontend/src/components/workflow/WorkflowCanvas.jsx
Normal file
70
frontend/src/components/workflow/WorkflowCanvas.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/workflow/nodes/AnalysisNode.jsx
Normal file
51
frontend/src/components/workflow/nodes/AnalysisNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/components/workflow/nodes/EndNode.jsx
Normal file
25
frontend/src/components/workflow/nodes/EndNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
frontend/src/components/workflow/nodes/JoinNode.jsx
Normal file
74
frontend/src/components/workflow/nodes/JoinNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
frontend/src/components/workflow/nodes/LogicNode.jsx
Normal file
68
frontend/src/components/workflow/nodes/LogicNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/components/workflow/nodes/StartNode.jsx
Normal file
25
frontend/src/components/workflow/nodes/StartNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/workflow/panels/FallbackConfig.jsx
Normal file
70
frontend/src/components/workflow/panels/FallbackConfig.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/workflow/panels/JoinConfig.jsx
Normal file
67
frontend/src/components/workflow/panels/JoinConfig.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">></option>
|
||||
<option value="lt"><</option>
|
||||
<option value="gte">>=</option>
|
||||
<option value="lte"><=</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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
392
frontend/src/pages/WorkflowEditorPage.jsx
Normal file
392
frontend/src/pages/WorkflowEditorPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
471
frontend/src/styles/workflowEditor.css
Normal file
471
frontend/src/styles/workflowEditor.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'}),
|
||||
|
|
|
|||
117
frontend/src/utils/workflowSerializer.js
Normal file
117
frontend/src/utils/workflowSerializer.js
Normal 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 || {}
|
||||
}
|
||||
}
|
||||
226
frontend/src/utils/workflowValidation.js
Normal file
226
frontend/src/utils/workflowValidation.js
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user