Merge pull request 'Workflow V1' (#72) from develop into main
Reviewed-on: #72
This commit is contained in:
commit
8b731313df
|
|
@ -12,6 +12,7 @@ Dieser Ordner ist der **primäre Orientierungspunkt** für Claude Code / Cursor-
|
||||||
| 2 | **`rules/DOCUMENTATION.md`** – Ablage- und Dokumentationsregeln |
|
| 2 | **`rules/DOCUMENTATION.md`** – Ablage- und Dokumentationsregeln |
|
||||||
| 3 | `rules/ARCHITECTURE.md`, `rules/CODING_RULES.md`, `rules/LESSONS_LEARNED.md` |
|
| 3 | `rules/ARCHITECTURE.md`, `rules/CODING_RULES.md`, `rules/LESSONS_LEARNED.md` |
|
||||||
| 4 | Issue-Landkarte: **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
| 4 | Issue-Landkarte: **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
||||||
|
| 5 | **Universal CSV Import** (Modul/Executor/Vorlagen): **`docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** (unter `.claude/`) |
|
||||||
|
|
||||||
Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im **Projekt**-`docs/`, nicht hier).
|
Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im **Projekt**-`docs/`, nicht hier).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Gitea Issues – Landkarte (Auswertung)
|
# Gitea Issues – Landkarte (Auswertung)
|
||||||
|
|
||||||
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-08** (Abfrage `state=all`).
|
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-09** (Abfrage `state=all`, ergänzt: #71).
|
||||||
**URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
|
**URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
|
||||||
|
|
||||||
Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung.
|
Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung.
|
||||||
|
|
@ -88,7 +88,6 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver
|
||||||
| # | Titel |
|
| # | Titel |
|
||||||
|---|--------|
|
|---|--------|
|
||||||
| 15 | [FEAT-002] Quality-Filter für KI-Auswertungen & Charts integrieren |
|
| 15 | [FEAT-002] Quality-Filter für KI-Auswertungen & Charts integrieren |
|
||||||
| 21 | [FEATURE] Universeller CSV-Parser mit lernbarem Feldmapping |
|
|
||||||
| 36 | BUG-009: Trainingstyp-Erstellung führt zu Internal Server Error |
|
| 36 | BUG-009: Trainingstyp-Erstellung führt zu Internal Server Error |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
||||||
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
|
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
|
||||||
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
||||||
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
||||||
|
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
|
||||||
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
|
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -111,6 +112,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
||||||
| `PROFILE_REFERENCE_VALUES.md` | Profil-Referenzwerte |
|
| `PROFILE_REFERENCE_VALUES.md` | Profil-Referenzwerte |
|
||||||
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
||||||
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
||||||
|
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
|
||||||
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -174,4 +176,4 @@ Siehe [`audit/README.md`](./audit/README.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 8. April 2026 (Struktur-Index, Duplikatbereinigung, Abgleich-Hinweise)
|
**Letzte Aktualisierung:** 9. April 2026 (Universal CSV Agent-Guide, Abgleich-Tabelle)
|
||||||
|
|
|
||||||
63
.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md
Normal file
63
.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Universal CSV Import – Agent-Leitfaden
|
||||||
|
|
||||||
|
**Stand:** 2026-04-09 · **Kontext:** Issue #21 (Universeller CSV-Parser), Prod-Migrationen u. a. 051–053.
|
||||||
|
|
||||||
|
Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul anlegen oder bestehende Import-Pfade (Executor, Vorlagen, DB) ändern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architektur (Kurz)
|
||||||
|
|
||||||
|
| Komponente | Pfad / Rolle |
|
||||||
|
|------------|----------------|
|
||||||
|
| Modul-Definitionen | `backend/csv_parser/module_registry.py` (`MODULE_DEFINITIONS`) |
|
||||||
|
| Typ-/Einheiten-Konvertierung | `backend/csv_parser/type_converter.py`, `field_units.py` |
|
||||||
|
| Zeilen-Aggregation (z. B. Ernährung pro Tag) | `backend/csv_parser/import_row_processing.py` |
|
||||||
|
| Import-Ausführung | `backend/csv_parser/executor.py` |
|
||||||
|
| Fehlertexte / Transaktions-Hinweise | `backend/csv_parser/import_errors.py` (`enrich_row_error`) |
|
||||||
|
| Admin-Systemvorlagen | `backend/routers/admin_csv_templates.py` |
|
||||||
|
| Nutzer-Import (Profil-Mappings) | `backend/routers/csv_import.py` |
|
||||||
|
| Vorlagen-Validierung (strukturell + Sample) | `backend/csv_parser/template_validator.py` (`validate_csv_template`) |
|
||||||
|
|
||||||
|
**Single Source of Truth** für erlaubte Zielfelder, Typen und Duplikat-Keys ist **`module_registry.py`**. Keine parallele Feldliste in Routern duplizieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Checkliste: Neues Zielmodul
|
||||||
|
|
||||||
|
1. **`MODULE_DEFINITIONS`** um Eintrag erweitern: `table`, `fields` (Typen `date` / `datetime` / `float` / `int` / `string`), `duplicate_key`, `duplicate_strategy`, ggf. `derive_date_from_datetime_field`, `import_mode` (Spezialpfade wie Schlaf).
|
||||||
|
2. **DB:** Migration nur nach Projektregel (`backend/migrations/NNN_*.sql`). Spaltenbreiten/Typen so wählen, dass importierte Werte (z. B. kJ→kcal, große Energiebeträge) **keinen NUMERIC-Overflow** verursachen.
|
||||||
|
3. **`source` / CHECK-Constraints:** Wenn die Zieltabelle `source` hat, muss der Wert **`csv`** (oder der vereinbarte Import-Tag) in der DB erlaubt sein (Migration anpassen, nicht nur App-Code).
|
||||||
|
4. **Executor:** Einfügen/Aktualisieren in `executor.py` nur über bestehende Muster (ein Cursor, **kein** verschachteltes `get_db()` im gleichen Request). Bei mehreren Zeilen pro Transaktion: bei **Zeilenfehlern** SAVEPOINT pro Zeile nutzen (siehe Activity-Pattern), damit die Transaktion nicht dauerhaft abgebrochen ist.
|
||||||
|
5. **Trainingstyp / FK-Auflösung:** DB-Zugriffe für abhängige Entitäten (z. B. `get_training_type_for_activity_with_cursor`) **mit dem gleichen Cursor** wie der Import – keine zweite Connection aus dem Importpfad.
|
||||||
|
6. **Vorlagen:** System-Templates in Migration/Seed pflegen (`csv_field_mappings`, `is_system=true`). `type_conversions` und `source_unit` dort setzen, wo Einheiten aus Exporten abweichen (z. B. Apple kJ).
|
||||||
|
7. **Validierung:** Neue/angepasste Admin-Vorlagen müssen **`validate_csv_template`** passieren (Create/Update liefert bei Fehlern **422** mit `validation`). Tests für Randfälle ergänzen (`tests/test_template_validator.py` o. ä.).
|
||||||
|
8. **API / Frontend:** Neue Admin-Endpunkte in `main.py` registrieren; Frontend **nur** über `api.js`. Bei strukturierten FastAPI-Fehlern (`detail` als Objekt/Liste) bestehende Hilfen (`formatFastApiDetail`) nutzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Checkliste: Bestehendes Modul ändern
|
||||||
|
|
||||||
|
- Schema-Änderung: Migration + ggf. **`module_registry`**-Felder anpassen.
|
||||||
|
- Neue Spalte im Import: Executor-Mapping, optional `type_conversions` / Validator.
|
||||||
|
- Änderung an Duplikatlogik: `duplicate_key` / `ON CONFLICT`-Pfad im Executor prüfen.
|
||||||
|
- Datums-/Zeit-Parsing: **`type_converter`** – ISO-Daten `YYYY-MM-DD` konsistent (**`dayfirst=False`**), Zeiten `HH:MM` ohne Sekunden unterstützen wo nötig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bekannte Einschränkungen (Follow-up in Gitea)
|
||||||
|
|
||||||
|
- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import.
|
||||||
|
- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** – Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Verwandte Regeln
|
||||||
|
|
||||||
|
- `.claude/rules/ARCHITECTURE.md` – Router, DB, `source`-Tracking
|
||||||
|
- `.claude/rules/CODING_RULES.md` – Kurzverweis Universal CSV
|
||||||
|
- `.claude/rules/DOCUMENTATION.md` – Ablage technischer Specs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
|
@ -216,8 +216,11 @@ updated_at TIMESTAMP DEFAULT NOW()
|
||||||
Tabellen die Daten aus externen Quellen empfangen brauchen:
|
Tabellen die Daten aus externen Quellen empfangen brauchen:
|
||||||
```sql
|
```sql
|
||||||
source VARCHAR(50) DEFAULT 'manual'
|
source VARCHAR(50) DEFAULT 'manual'
|
||||||
-- Werte: 'manual' | 'apple_health' | 'garmin' | 'withings'
|
-- Werte u. a.: 'manual' | 'apple_health' | 'garmin' | 'withings' | 'csv'
|
||||||
```
|
```
|
||||||
|
Importe über den **Universal CSV**-Pfad setzen `source = 'csv'`, sofern die Tabelle ein `source`-Feld hat; CHECK-Constraints und Migrationen müssen diesen Wert erlauben.
|
||||||
|
|
||||||
|
**Agent-Pflicht bei neuen Import-Zielen oder Executor-Änderungen:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`
|
||||||
|
|
||||||
Manuelle Einträge (`source = 'manual'`) haben IMMER Vorrang bei Reimport:
|
Manuelle Einträge (`source = 'manual'`) haben IMMER Vorrang bei Reimport:
|
||||||
```sql
|
```sql
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ from slowapi import Limiter
|
||||||
def sensitive(request: Request, ...):
|
def sensitive(request: Request, ...):
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 6. Universal CSV Import / Admin-Vorlagen
|
||||||
|
Neues **Import-Zielmodul**, Änderungen an **`csv_parser`**, Executor, DB-`source`/`CHECK`, oder System-CSV-Vorlagen:
|
||||||
|
|
||||||
|
- Pflichtlektüre und Checkliste: **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`**
|
||||||
|
- Keine zweite DB-Connection im Importpfad; Zeilenfehler ohne „aborted transaction“ (SAVEPOINT-Muster wo nötig)
|
||||||
|
- Admin Create/Update von Systemvorlagen: Validierung über `validate_csv_template` nicht umgehen
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
### 1. api.js für alle API-Calls
|
### 1. api.js für alle API-Calls
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
||||||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||||
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
||||||
|
> | **Universal CSV Import** (neues Modul / Executor / Vorlagen) | **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** |
|
||||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||||
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||||
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
||||||
|
|
@ -98,6 +99,12 @@ frontend/src/
|
||||||
**Branch:** develop
|
**Branch:** develop
|
||||||
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
|
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
|
||||||
|
|
||||||
|
### Updates (09.04.2026 - Universal CSV Import, Prod-Migration abgeschlossen)
|
||||||
|
|
||||||
|
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
|
||||||
|
- **Regeln:** Verweise in `.claude/rules/ARCHITECTURE.md` (§3.2 `source`), `.claude/rules/CODING_RULES.md` (§6)
|
||||||
|
- **Follow-ups:** **Gitea #71** – Dry-Run inkl. `import_row_processing`, Nutzer-Mapping-Validierung, Fehler-Hints in der Import-UI ([Issue](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71))
|
||||||
|
|
||||||
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
|
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
|
||||||
|
|
||||||
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` — **P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.
|
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` — **P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
- PATCH: Bugfix, kleine Änderung, Refactor
|
- PATCH: Bugfix, kleine Änderung, Refactor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "0.9p"
|
APP_VERSION = "0.9q"
|
||||||
BUILD_DATE = "2026-04-09"
|
BUILD_DATE = "2026-04-11"
|
||||||
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
|
|
@ -29,13 +29,26 @@ MODULE_VERSIONS = {
|
||||||
"exportdata": "1.1.0",
|
"exportdata": "1.1.0",
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
||||||
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
|
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
|
||||||
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
|
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
|
||||||
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
|
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.9q",
|
||||||
|
"date": "2026-04-11",
|
||||||
|
"changes": [
|
||||||
|
"Workflow Engine Part 3: Inline Prompts",
|
||||||
|
"Frontend: Radio Buttons (Reference/Inline), InlineTemplateEditor Component",
|
||||||
|
"Frontend: Placeholder Picker für Inline-Templates, Cursor-Position Tracking",
|
||||||
|
"Backend: load_prompt_template() unterstützt inline_template",
|
||||||
|
"Backend: WorkflowNode.inline_template Feld hinzugefügt",
|
||||||
|
"Serialization: inline_template speichern/laden in graph_data",
|
||||||
|
"Validation: Prüft dass entweder prompt_slug ODER inline_template gesetzt",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.9p",
|
"version": "0.9p",
|
||||||
"date": "2026-04-09",
|
"date": "2026-04-09",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import json
|
||||||
from jinja2 import Environment, ChainableUndefined, TemplateError
|
from jinja2 import Environment, ChainableUndefined, TemplateError
|
||||||
|
|
||||||
from workflow_models import (
|
from workflow_models import (
|
||||||
WorkflowGraph, NodeExecutionState, ExecutionResult,
|
WorkflowGraph, WorkflowNode, NodeExecutionState, ExecutionResult,
|
||||||
NodeStatus, NormalizedSignal, FallbackStrategy, SignalStatus,
|
NodeStatus, NormalizedSignal, FallbackStrategy, SignalStatus,
|
||||||
EndNodeOutputMode
|
EndNodeOutputMode
|
||||||
)
|
)
|
||||||
|
|
@ -278,9 +278,10 @@ async def execute_node(
|
||||||
|
|
||||||
# Analysis Nodes
|
# Analysis Nodes
|
||||||
if node.type == "analysis":
|
if node.type == "analysis":
|
||||||
# 1. Lade Prompt
|
# 1. Lade Prompt (Part 3: inline_template support)
|
||||||
prompt_template = await load_prompt_template(node.prompt_slug, context)
|
prompt_template = await load_prompt_template(node, context)
|
||||||
logger.debug(f"Node {node.id}: Loaded prompt '{node.prompt_slug}'")
|
source_type = "inline" if node.inline_template else "reference"
|
||||||
|
logger.debug(f"Node {node.id}: Loaded prompt from {source_type}")
|
||||||
|
|
||||||
# 2. Parse question_augmentations
|
# 2. Parse question_augmentations
|
||||||
questions = []
|
questions = []
|
||||||
|
|
@ -812,39 +813,64 @@ def _has_active_incoming_edge(node, graph: WorkflowGraph, context: Dict[str, Any
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def load_prompt_template(prompt_slug: str, context: Dict[str, Any]) -> str:
|
async def load_prompt_template(node: WorkflowNode, context: Dict[str, Any]) -> str:
|
||||||
"""
|
"""
|
||||||
Lädt Prompt-Template aus DB und resolved Platzhalter.
|
Lädt Prompt-Template aus DB (reference mode) oder direkt vom Node (inline mode).
|
||||||
|
|
||||||
|
Part 3: Inline Prompts - Unterstützt zwei Modi:
|
||||||
|
- Reference Mode: prompt_slug → Template aus ai_prompts Tabelle
|
||||||
|
- Inline Mode: inline_template → Template direkt vom Node
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt_slug: Slug des Prompts (z.B. "pipeline_body")
|
node: WorkflowNode mit prompt_slug ODER inline_template
|
||||||
context: {"variables": {"name": "Lars", ...}, "profile_id": "..."}
|
context: {"variables": {"name": "Lars", ...}, "profile_id": "..."}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Resolved prompt template
|
Resolved prompt template
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: Wenn weder prompt_slug noch inline_template gesetzt
|
||||||
|
|
||||||
Beispiel:
|
Beispiel:
|
||||||
>>> template = await load_prompt_template("pipeline_body", {"profile_id": "123"})
|
>>> node = WorkflowNode(id="n1", prompt_slug="pipeline_body")
|
||||||
|
>>> template = await load_prompt_template(node, {"profile_id": "123"})
|
||||||
>>> "{{name}}" not in template
|
>>> "{{name}}" not in template
|
||||||
True
|
True
|
||||||
"""
|
"""
|
||||||
from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog
|
from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog
|
||||||
from prompt_executor import resolve_placeholders
|
from prompt_executor import resolve_placeholders
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
with get_db() as conn:
|
# Mode 1: Inline Template (NEU)
|
||||||
cur = get_cursor(conn)
|
if node.inline_template:
|
||||||
cur.execute(
|
logger.debug(f"Node {node.id}: Using inline template ({len(node.inline_template)} chars)")
|
||||||
"SELECT template FROM ai_prompts WHERE slug = %s AND active = true",
|
template = node.inline_template
|
||||||
(prompt_slug,)
|
|
||||||
|
# Mode 2: Reference (bestehend)
|
||||||
|
elif node.prompt_slug:
|
||||||
|
logger.debug(f"Node {node.id}: Loading prompt '{node.prompt_slug}' from DB")
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT template FROM ai_prompts WHERE slug = %s AND active = true",
|
||||||
|
(node.prompt_slug,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Prompt not found: {node.prompt_slug}")
|
||||||
|
template = row['template']
|
||||||
|
|
||||||
|
# Mode 3: Error - weder inline noch reference
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Node {node.id}: Either prompt_slug or inline_template required"
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise ValueError(f"Prompt not found: {prompt_slug}")
|
|
||||||
|
|
||||||
template = row['template']
|
|
||||||
|
|
||||||
# Resolve Placeholders using modern prompt_executor method
|
# Resolve Placeholders using modern prompt_executor method
|
||||||
profile_id = context.get("profile_id")
|
profile_id = context.get("profile_id")
|
||||||
|
if not profile_id:
|
||||||
|
raise HTTPException(status_code=400, detail="profile_id required in context")
|
||||||
|
|
||||||
# Build variables dict with ALL registered placeholders
|
# Build variables dict with ALL registered placeholders
|
||||||
variables = {}
|
variables = {}
|
||||||
|
|
@ -852,14 +878,51 @@ async def load_prompt_template(prompt_slug: str, context: Dict[str, Any]) -> str
|
||||||
try:
|
try:
|
||||||
# Get all placeholder values from registry
|
# Get all placeholder values from registry
|
||||||
processed_placeholders = get_placeholder_example_values(profile_id)
|
processed_placeholders = get_placeholder_example_values(profile_id)
|
||||||
|
logger.info(f"🔍 DEBUG: Loaded {len(processed_placeholders)} placeholders from registry")
|
||||||
|
logger.info(f"🔍 DEBUG: Sample keys (first 3): {list(processed_placeholders.keys())[:3]}")
|
||||||
|
|
||||||
# Remove {{ }} from keys (placeholder_resolver returns them with wrappers)
|
# Remove {{ }} from keys (placeholder_resolver returns them with wrappers)
|
||||||
cleaned_placeholders = {
|
cleaned_placeholders = {
|
||||||
key.replace('{{', '').replace('}}', ''): value
|
key.replace('{{', '').replace('}}', '').strip(): value
|
||||||
for key, value in processed_placeholders.items()
|
for key, value in processed_placeholders.items()
|
||||||
}
|
}
|
||||||
|
logger.info(f"🔍 DEBUG: Cleaned keys (first 3): {list(cleaned_placeholders.keys())[:3]}")
|
||||||
|
logger.info(f"🔍 DEBUG: Sample values: name={cleaned_placeholders.get('name')}, age={cleaned_placeholders.get('age')}, geschlecht={cleaned_placeholders.get('geschlecht')}")
|
||||||
|
|
||||||
variables.update(cleaned_placeholders)
|
variables.update(cleaned_placeholders)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load placeholders for workflow: {e}")
|
logger.error(f"❌ CRITICAL: Failed to load placeholders for workflow: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Add workflow node outputs as placeholders (Part 3: Inline Prompts)
|
||||||
|
# Format: node_id.analysis_core, node_id.signal_xyz, node_id.question_xyz
|
||||||
|
node_results = context.get("node_results", {})
|
||||||
|
if node_results:
|
||||||
|
logger.info(f"🔍 DEBUG: Adding {len(node_results)} node outputs as placeholders")
|
||||||
|
for node_id, node_state in node_results.items():
|
||||||
|
# analysis_core
|
||||||
|
if hasattr(node_state, 'analysis_core') and node_state.analysis_core:
|
||||||
|
key = f"{node_id}.analysis_core"
|
||||||
|
variables[key] = node_state.analysis_core
|
||||||
|
logger.debug(f" Added placeholder: {key} = {node_state.analysis_core[:50]}...")
|
||||||
|
|
||||||
|
# decision_signals (keyed by question ID)
|
||||||
|
if hasattr(node_state, 'decision_signals') and node_state.decision_signals:
|
||||||
|
for signal_id, signal_value in node_state.decision_signals.items():
|
||||||
|
# Signal placeholder: node_id.signal_question_id
|
||||||
|
signal_key = f"{node_id}.signal_{signal_id}"
|
||||||
|
variables[signal_key] = signal_value
|
||||||
|
logger.debug(f" Added placeholder: {signal_key} = {signal_value}")
|
||||||
|
|
||||||
|
# Question texts (from graph metadata if available)
|
||||||
|
# NOTE: Question text placeholders are populated from graph in PlaceholderPicker
|
||||||
|
# Here we only add if available in node_state metadata
|
||||||
|
if hasattr(node_state, 'metadata') and isinstance(node_state.metadata, dict):
|
||||||
|
questions = node_state.metadata.get('questions', [])
|
||||||
|
for q in questions:
|
||||||
|
if isinstance(q, dict) and 'id' in q and 'question' in q:
|
||||||
|
question_key = f"{node_id}.question_{q['id']}"
|
||||||
|
variables[question_key] = q['question']
|
||||||
|
logger.debug(f" Added placeholder: {question_key}")
|
||||||
|
|
||||||
# Load catalog for |d modifier support
|
# Load catalog for |d modifier support
|
||||||
try:
|
try:
|
||||||
|
|
@ -868,13 +931,22 @@ async def load_prompt_template(prompt_slug: str, context: Dict[str, Any]) -> str
|
||||||
catalog = None
|
catalog = None
|
||||||
logger.warning(f"Failed to load placeholder catalog for workflow: {e}")
|
logger.warning(f"Failed to load placeholder catalog for workflow: {e}")
|
||||||
|
|
||||||
|
logger.info(f"🔍 DEBUG: Template before resolution:\n{template[:200]}...")
|
||||||
|
logger.info(f"🔍 DEBUG: Variables dict has {len(variables)} entries")
|
||||||
|
|
||||||
# Resolve with modern executor
|
# Resolve with modern executor
|
||||||
|
debug_info = {}
|
||||||
resolved = resolve_placeholders(
|
resolved = resolve_placeholders(
|
||||||
template=template,
|
template=template,
|
||||||
variables=variables,
|
variables=variables,
|
||||||
|
debug_info=debug_info,
|
||||||
catalog=catalog
|
catalog=catalog
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"🔍 DEBUG: Resolved placeholders: {debug_info.get('resolved_placeholders', {})}")
|
||||||
|
logger.info(f"🔍 DEBUG: Unresolved placeholders: {debug_info.get('unresolved_placeholders', [])}")
|
||||||
|
logger.info(f"🔍 DEBUG: Template after resolution:\n{resolved[:200]}...")
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,8 @@ class WorkflowNode(BaseModel):
|
||||||
position: Optional[Position] = Field(None, description="Position im visuellen Editor")
|
position: Optional[Position] = Field(None, description="Position im visuellen Editor")
|
||||||
|
|
||||||
# ANALYSIS-Knoten
|
# ANALYSIS-Knoten
|
||||||
prompt_slug: Optional[str] = Field(None, description="Slug des auszuführenden Prompts")
|
prompt_slug: Optional[str] = Field(None, description="Slug des auszuführenden Prompts (reference mode)")
|
||||||
|
inline_template: Optional[str] = Field(None, description="Inline-Prompt-Template (inline mode, Part 3)")
|
||||||
question_augmentations: Optional[List[QuestionAugmentation]] = Field(None, description="Fragenergänzungen (knotengebunden, überschreiben Prompt-Defaults)")
|
question_augmentations: Optional[List[QuestionAugmentation]] = Field(None, description="Fragenergänzungen (knotengebunden, überschreiben Prompt-Defaults)")
|
||||||
|
|
||||||
# LOGIC-Knoten
|
# LOGIC-Knoten
|
||||||
|
|
|
||||||
106
frontend/src/components/ConfirmDialog.jsx
Normal file
106
frontend/src/components/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* ConfirmDialog Component
|
||||||
|
*
|
||||||
|
* Modal confirmation dialog
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - message: string
|
||||||
|
* - onConfirm: callback when confirmed
|
||||||
|
* - onCancel: callback when cancelled
|
||||||
|
* - confirmText: string (default: 'OK')
|
||||||
|
* - cancelText: string (default: 'Abbrechen')
|
||||||
|
* - type: 'warning' | 'danger' | 'info' (default: 'warning')
|
||||||
|
*/
|
||||||
|
export function ConfirmDialog({
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
confirmText = 'OK',
|
||||||
|
cancelText = 'Abbrechen',
|
||||||
|
type = 'warning'
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
warning: '#FFC107',
|
||||||
|
danger: 'var(--danger)',
|
||||||
|
info: 'var(--accent)'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 20000
|
||||||
|
}}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.3)'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
color: 'var(--text1)',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
color: 'var(--text1)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: colors[type],
|
||||||
|
color: type === 'warning' ? '#000' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
frontend/src/components/Toast.jsx
Normal file
95
frontend/src/components/Toast.jsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast Notification Component
|
||||||
|
*
|
||||||
|
* Auto-closing notification that appears at the top of the screen
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - message: string
|
||||||
|
* - type: 'success' | 'error' | 'warning' | 'info'
|
||||||
|
* - duration: number (ms, default 3000)
|
||||||
|
* - onClose: callback when toast closes
|
||||||
|
*/
|
||||||
|
export function Toast({ message, type = 'info', duration = 3000, onClose }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (onClose) onClose()
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [duration, onClose])
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
success: {
|
||||||
|
background: '#4CAF50',
|
||||||
|
color: 'white',
|
||||||
|
icon: '✅'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: 'white',
|
||||||
|
icon: '❌'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
background: '#FFC107',
|
||||||
|
color: '#856404',
|
||||||
|
icon: '⚠️'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: 'white',
|
||||||
|
icon: 'ℹ️'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = styles[type] || styles.info
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '80px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: style.background,
|
||||||
|
color: style.color,
|
||||||
|
padding: '12px 24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
zIndex: 10000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '300px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
animation: 'slideDown 0.3s ease-out'
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '18px' }}>{style.icon}</span>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add animation CSS if not already in global styles
|
||||||
|
const styleElement = document.createElement('style')
|
||||||
|
styleElement.textContent = `
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if (!document.querySelector('style[data-toast-styles]')) {
|
||||||
|
styleElement.setAttribute('data-toast-styles', 'true')
|
||||||
|
document.head.appendChild(styleElement)
|
||||||
|
}
|
||||||
|
|
@ -5,16 +5,22 @@ import { Handle, Position } from 'reactflow'
|
||||||
*
|
*
|
||||||
* Properties:
|
* Properties:
|
||||||
* - data.label: Node-Label
|
* - data.label: Node-Label
|
||||||
* - data.prompt_slug: Slug des referenzierten Basis-Prompts
|
* - data.prompt_slug: Slug des referenzierten Basis-Prompts (Reference Mode)
|
||||||
|
* - data.inline_template: Inline Prompt-Template (Inline Mode)
|
||||||
* - data.prompt_name: Name des Prompts (optional, für Display)
|
* - data.prompt_name: Name des Prompts (optional, für Display)
|
||||||
* - data.questions: Array von Question Augmentations
|
* - data.questions: Array von Question Augmentations
|
||||||
* - selected: Boolean
|
* - selected: Boolean
|
||||||
*/
|
*/
|
||||||
export function AnalysisNode({ data, selected }) {
|
export function AnalysisNode({ data, selected }) {
|
||||||
const hasQuestions = data.questions?.length > 0
|
const hasQuestions = data.questions?.length > 0
|
||||||
const promptName = data.prompt_name || (data.prompt_slug ? `Prompt: ${data.prompt_slug}` : 'Kein Prompt')
|
|
||||||
const questionCount = data.questions?.length || 0
|
const questionCount = data.questions?.length || 0
|
||||||
|
|
||||||
|
// Part 3: Inline Prompts - Zeige "Inline-Template" oder Prompt-Namen
|
||||||
|
const isInlineMode = data.inline_template !== null && data.inline_template !== undefined
|
||||||
|
const promptName = isInlineMode
|
||||||
|
? '✏️ Inline-Template'
|
||||||
|
: (data.prompt_name || (data.prompt_slug ? `Prompt: ${data.prompt_slug}` : 'Kein Prompt'))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`workflow-node analysis-node ${selected ? 'selected' : ''}`}>
|
<div className={`workflow-node analysis-node ${selected ? 'selected' : ''}`}>
|
||||||
<div className="node-header">
|
<div className="node-header">
|
||||||
|
|
|
||||||
|
|
@ -42,24 +42,54 @@ export function JoinNode({ data, selected }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mehrere Target Handles für eingehende Pfade */}
|
{/* Mehrere Target Handles für eingehende Pfade (bis zu 8) */}
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
id="path_1"
|
id="path_1"
|
||||||
style={{ left: '25%', background: '#17A2B8' }}
|
style={{ left: '11%', background: '#17A2B8' }}
|
||||||
/>
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
id="path_2"
|
id="path_2"
|
||||||
style={{ left: '50%', background: '#17A2B8' }}
|
style={{ left: '22%', background: '#17A2B8' }}
|
||||||
/>
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
id="path_3"
|
id="path_3"
|
||||||
style={{ left: '75%', background: '#17A2B8' }}
|
style={{ left: '33%', background: '#17A2B8' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="path_4"
|
||||||
|
style={{ left: '44%', background: '#17A2B8' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="path_5"
|
||||||
|
style={{ left: '56%', background: '#17A2B8' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="path_6"
|
||||||
|
style={{ left: '67%', background: '#17A2B8' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="path_7"
|
||||||
|
style={{ left: '78%', background: '#17A2B8' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="path_8"
|
||||||
|
style={{ left: '89%', background: '#17A2B8' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Ein Source Handle für konsolidierten Ausgang */}
|
{/* Ein Source Handle für konsolidierten Ausgang */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InlineTemplateEditor - Template-Editor für Inline-Prompts
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - value: Template-String
|
||||||
|
* - onChange: (template) => void
|
||||||
|
* - onPlaceholderPick: () => void - Öffnet Placeholder Picker
|
||||||
|
* - textareaRef: Ref für Cursor-Position (von Parent)
|
||||||
|
*/
|
||||||
|
export function InlineTemplateEditor({ value, onChange, onPlaceholderPick, textareaRef }) {
|
||||||
|
return (
|
||||||
|
<div className="inline-template-editor" style={{ marginTop: '12px' }}>
|
||||||
|
<label className="form-label">Template</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="Analysiere folgende Daten: Gewicht: {{ weight_current }} Ziel: {{ goal_weight }} Gib eine Empfehlung..."
|
||||||
|
rows={12}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '12px',
|
||||||
|
paddingRight: '120px', // Platz für Button
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
color: 'var(--text1)',
|
||||||
|
resize: 'vertical',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={onPlaceholderPick}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8px',
|
||||||
|
right: '8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '6px 10px',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{'{{ }}'} Platzhalter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="help-text"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text3)',
|
||||||
|
marginTop: '6px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
💡 Tipp: Verwende <code style={{
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}>{'{{ placeholder_name }}'}</code> für dynamische Werte
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,17 +6,19 @@ import { api } from '../../../utils/api'
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* - nodes: Array of workflow nodes (to extract workflow-specific placeholders)
|
* - nodes: Array of workflow nodes (to extract workflow-specific placeholders)
|
||||||
|
* - currentNodeId: ID des aktuellen Nodes (wird aus Placeholders ausgeschlossen)
|
||||||
* - onSelect: (placeholderString) => void - Callback when placeholder is selected
|
* - onSelect: (placeholderString) => void - Callback when placeholder is selected
|
||||||
* - onClose: () => void
|
* - onClose: () => void
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Lädt registrierte Platzhalter vom Backend (~120+)
|
* - Lädt registrierte Platzhalter vom Backend (~120+)
|
||||||
* - Extrahiert Workflow-spezifische Node-Outputs
|
* - Extrahiert Workflow-spezifische Node-Outputs
|
||||||
|
* - Filtert Selbst-Referenzierung (Node kann sich nicht selbst referenzieren)
|
||||||
* - Zeigt Node-Namen (nicht nur IDs)
|
* - Zeigt Node-Namen (nicht nur IDs)
|
||||||
* - Kategorisiert: System + Workflow
|
* - Kategorisiert: System + Workflow
|
||||||
* - Suchfunktion über alle Kategorien
|
* - Suchfunktion über alle Kategorien
|
||||||
*/
|
*/
|
||||||
export function PlaceholderPicker({ nodes, onSelect, onClose }) {
|
export function PlaceholderPicker({ nodes, currentNodeId, onSelect, onClose }) {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [systemPlaceholders, setSystemPlaceholders] = useState([])
|
const [systemPlaceholders, setSystemPlaceholders] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -61,8 +63,8 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
|
||||||
loadPlaceholders()
|
loadPlaceholders()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Extrahiere Workflow-spezifische Platzhalter
|
// Extrahiere Workflow-spezifische Platzhalter (ohne aktuellen Node)
|
||||||
const workflowPlaceholders = extractWorkflowPlaceholders(nodes)
|
const workflowPlaceholders = extractWorkflowPlaceholders(nodes, currentNodeId)
|
||||||
|
|
||||||
// Kombiniere beide Listen
|
// Kombiniere beide Listen
|
||||||
const allPlaceholders = [
|
const allPlaceholders = [
|
||||||
|
|
@ -341,14 +343,19 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrahiert Workflow-spezifische Platzhalter aus Nodes
|
* Extrahiert Workflow-spezifische Platzhalter aus Nodes
|
||||||
|
*
|
||||||
|
* @param {Array} nodes - Alle Workflow-Nodes
|
||||||
|
* @param {string} currentNodeId - ID des aktuellen Nodes (wird ausgeschlossen)
|
||||||
*/
|
*/
|
||||||
function extractWorkflowPlaceholders(nodes) {
|
function extractWorkflowPlaceholders(nodes, currentNodeId) {
|
||||||
const placeholders = []
|
const placeholders = []
|
||||||
|
|
||||||
console.log('🔍 Extracting workflow placeholders from nodes:', nodes)
|
console.log('🔍 Extracting workflow placeholders from nodes:', nodes)
|
||||||
|
console.log('🚫 Excluding current node:', currentNodeId)
|
||||||
|
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
if (node.type === 'end') return // End Node hat keine Outputs
|
if (node.type === 'end') return // End Node hat keine Outputs
|
||||||
|
if (node.id === currentNodeId) return // Selbst-Referenzierung verhindern
|
||||||
|
|
||||||
const nodeId = node.id
|
const nodeId = node.id
|
||||||
const nodeLabel = node.data?.label || nodeId
|
const nodeLabel = node.data?.label || nodeId
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import { EndNodeConfig } from '../components/workflow/panels/EndNodeConfig'
|
||||||
import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPicker'
|
import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPicker'
|
||||||
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
|
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
|
||||||
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
|
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
|
||||||
|
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
|
||||||
|
import { Toast } from '../components/Toast'
|
||||||
|
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||||
import '../styles/workflowEditor.css'
|
import '../styles/workflowEditor.css'
|
||||||
|
|
||||||
// Node-Type Mapping
|
// Node-Type Mapping
|
||||||
|
|
@ -50,7 +53,13 @@ export default function WorkflowEditorPage() {
|
||||||
const [availablePrompts, setAvailablePrompts] = useState([])
|
const [availablePrompts, setAvailablePrompts] = useState([])
|
||||||
const [executionResult, setExecutionResult] = useState(null)
|
const [executionResult, setExecutionResult] = useState(null)
|
||||||
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
|
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
|
||||||
|
const [placeholderPickerTarget, setPlaceholderPickerTarget] = useState('end') // 'end' | 'inline'
|
||||||
const endNodeTextareaRef = useRef(null)
|
const endNodeTextareaRef = useRef(null)
|
||||||
|
const inlineTemplateTextareaRef = useRef(null)
|
||||||
|
|
||||||
|
// Toast & Confirm Dialog
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
// Load available basis prompts for Analysis nodes
|
// Load available basis prompts for Analysis nodes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -152,7 +161,7 @@ export default function WorkflowEditorPage() {
|
||||||
description: workflowDescription,
|
description: workflowDescription,
|
||||||
graph_data
|
graph_data
|
||||||
})
|
})
|
||||||
alert('Workflow gespeichert!')
|
setToast({ message: '✅ Workflow gespeichert!', type: 'success' })
|
||||||
} else {
|
} else {
|
||||||
// Create new
|
// Create new
|
||||||
console.log('✨ Creating new workflow')
|
console.log('✨ Creating new workflow')
|
||||||
|
|
@ -164,7 +173,7 @@ export default function WorkflowEditorPage() {
|
||||||
})
|
})
|
||||||
console.log('✅ Workflow created:', result)
|
console.log('✅ Workflow created:', result)
|
||||||
setCurrentPrompt({ id: result.id, slug: result.slug, name: workflowName })
|
setCurrentPrompt({ id: result.id, slug: result.slug, name: workflowName })
|
||||||
alert('Workflow erstellt!')
|
setToast({ message: '✅ Workflow erstellt!', type: 'success' })
|
||||||
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
|
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
|
||||||
navigate(`/workflow-editor/${result.id}`)
|
navigate(`/workflow-editor/${result.id}`)
|
||||||
}
|
}
|
||||||
|
|
@ -262,30 +271,60 @@ export default function WorkflowEditorPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePlaceholderSelect = (placeholderString) => {
|
const handlePlaceholderSelect = (placeholderString) => {
|
||||||
if (!selectedNode || selectedNode.type !== 'end') return
|
if (!selectedNode) return
|
||||||
|
|
||||||
const textarea = endNodeTextareaRef.current
|
// Target bestimmen: End Node oder Inline Template
|
||||||
const currentTemplate = selectedNode.data.template || ''
|
if (placeholderPickerTarget === 'end' && selectedNode.type === 'end') {
|
||||||
|
const textarea = endNodeTextareaRef.current
|
||||||
|
const currentTemplate = selectedNode.data.template || ''
|
||||||
|
|
||||||
// Wenn Textarea Ref verfügbar, an Cursor-Position einfügen
|
// Wenn Textarea Ref verfügbar, an Cursor-Position einfügen
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
const cursorPos = textarea.selectionStart || currentTemplate.length
|
const cursorPos = textarea.selectionStart || currentTemplate.length
|
||||||
const before = currentTemplate.substring(0, cursorPos)
|
const before = currentTemplate.substring(0, cursorPos)
|
||||||
const after = currentTemplate.substring(cursorPos)
|
const after = currentTemplate.substring(cursorPos)
|
||||||
const newTemplate = before + placeholderString + after
|
const newTemplate = before + placeholderString + after
|
||||||
|
|
||||||
handleNodeUpdate(selectedNode.id, { template: newTemplate })
|
handleNodeUpdate(selectedNode.id, { template: newTemplate })
|
||||||
|
|
||||||
// Cursor nach eingefügtem Platzhalter positionieren
|
// Cursor nach eingefügtem Platzhalter positionieren
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const newPos = cursorPos + placeholderString.length
|
const newPos = cursorPos + placeholderString.length
|
||||||
textarea.setSelectionRange(newPos, newPos)
|
textarea.setSelectionRange(newPos, newPos)
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Am Ende einfügen
|
// Fallback: Am Ende einfügen
|
||||||
const newTemplate = currentTemplate + placeholderString
|
const newTemplate = currentTemplate + placeholderString
|
||||||
handleNodeUpdate(selectedNode.id, { template: newTemplate })
|
handleNodeUpdate(selectedNode.id, { template: newTemplate })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline Template (Analysis Node)
|
||||||
|
else if (placeholderPickerTarget === 'inline' && selectedNode.type === 'analysis') {
|
||||||
|
const textarea = inlineTemplateTextareaRef.current
|
||||||
|
const currentTemplate = selectedNode.data.inline_template || ''
|
||||||
|
|
||||||
|
// Wenn Textarea Ref verfügbar, an Cursor-Position einfügen
|
||||||
|
if (textarea) {
|
||||||
|
const cursorPos = textarea.selectionStart || currentTemplate.length
|
||||||
|
const before = currentTemplate.substring(0, cursorPos)
|
||||||
|
const after = currentTemplate.substring(cursorPos)
|
||||||
|
const newTemplate = before + placeholderString + after
|
||||||
|
|
||||||
|
handleNodeUpdate(selectedNode.id, { inline_template: newTemplate })
|
||||||
|
|
||||||
|
// Cursor nach eingefügtem Platzhalter positionieren
|
||||||
|
setTimeout(() => {
|
||||||
|
const newPos = cursorPos + placeholderString.length
|
||||||
|
textarea.setSelectionRange(newPos, newPos)
|
||||||
|
textarea.focus()
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
// Fallback: Am Ende einfügen
|
||||||
|
const newTemplate = currentTemplate + placeholderString
|
||||||
|
handleNodeUpdate(selectedNode.id, { inline_template: newTemplate })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -429,6 +468,33 @@ export default function WorkflowEditorPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline Validation Display */}
|
||||||
|
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
|
||||||
|
<div style={{
|
||||||
|
background: validationErrors.length > 0 ? '#ffebee' : '#fff3cd',
|
||||||
|
border: `1px solid ${validationErrors.length > 0 ? 'var(--danger)' : '#FFC107'}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '8px', color: validationErrors.length > 0 ? 'var(--danger)' : '#856404' }}>
|
||||||
|
{validationErrors.length > 0 ? `❌ ${validationErrors.length} Fehler` : `⚠️ ${validationWarnings.length} Warnungen`}
|
||||||
|
</div>
|
||||||
|
{validationErrors.map((err, i) => (
|
||||||
|
<div key={`err-${i}`} style={{ fontSize: '13px', marginBottom: '4px', color: 'var(--danger)', cursor: err.nodeId ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => err.nodeId && setSelectedNodeId(err.nodeId)}>
|
||||||
|
• {err.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{validationWarnings.map((warn, i) => (
|
||||||
|
<div key={`warn-${i}`} style={{ fontSize: '13px', marginBottom: '4px', color: '#856404', cursor: warn.nodeId ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => warn.nodeId && setSelectedNodeId(warn.nodeId)}>
|
||||||
|
• {warn.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Basis-Konfiguration */}
|
{/* Basis-Konfiguration */}
|
||||||
<div className="config-section">
|
<div className="config-section">
|
||||||
<label>Node-Name</label>
|
<label>Node-Name</label>
|
||||||
|
|
@ -453,49 +519,112 @@ export default function WorkflowEditorPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type-spezifische Konfiguration */}
|
{/* Type-spezifische Konfiguration */}
|
||||||
{selectedNode.type === 'analysis' && (
|
{selectedNode.type === 'analysis' && (() => {
|
||||||
<>
|
// Helper: Bestimme aktuellen Mode basierend auf node.data
|
||||||
<div className="config-section">
|
const isInlineMode = selectedNode.data.inline_template !== null && selectedNode.data.inline_template !== undefined
|
||||||
<label>KI-Prompt auswählen</label>
|
const isReferenceMode = !isInlineMode
|
||||||
<select
|
|
||||||
value={selectedNode.data.prompt_slug ? String(selectedNode.data.prompt_slug) : ''}
|
return (
|
||||||
onChange={(e) => {
|
<>
|
||||||
const promptSlug = e.target.value
|
{/* Prompt Source Selector */}
|
||||||
console.log('🎯 Prompt selected:', promptSlug, 'Type:', typeof promptSlug)
|
<div className="config-section">
|
||||||
const selectedPrompt = availablePrompts.find(p => p.slug === promptSlug)
|
<label className="form-label">Prompt-Quelle</label>
|
||||||
console.log('📋 Selected prompt object:', selectedPrompt)
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
handleNodeUpdate(selectedNode.id, {
|
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||||
prompt_slug: promptSlug || null,
|
<input
|
||||||
prompt_name: selectedPrompt?.name || null
|
type="radio"
|
||||||
})
|
name={`promptSource-${selectedNode.id}`}
|
||||||
}}
|
checked={isReferenceMode}
|
||||||
style={{
|
onChange={() => {
|
||||||
width: '100%',
|
// Wechsel zu Reference Mode
|
||||||
padding: '8px',
|
handleNodeUpdate(selectedNode.id, {
|
||||||
borderRadius: '4px',
|
inline_template: null, // Inline löschen
|
||||||
border: '1px solid var(--border)',
|
prompt_slug: selectedNode.data.prompt_slug || '' // Behalte existierenden slug
|
||||||
background: 'var(--surface)',
|
})
|
||||||
color: 'var(--text1)'
|
}}
|
||||||
}}
|
style={{ marginRight: '8px' }}
|
||||||
>
|
/>
|
||||||
<option value="">-- Basis-Prompt wählen --</option>
|
<span style={{ fontSize: '14px' }}>📚 Basis-Prompt referenzieren</span>
|
||||||
{availablePrompts.map(prompt => (
|
</label>
|
||||||
<option key={prompt.id} value={prompt.slug}>
|
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||||
{prompt.name}
|
<input
|
||||||
</option>
|
type="radio"
|
||||||
))}
|
name={`promptSource-${selectedNode.id}`}
|
||||||
</select>
|
checked={isInlineMode}
|
||||||
{selectedNode.data.prompt_slug && (
|
onChange={() => {
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text3)' }}>
|
// Wechsel zu Inline Mode
|
||||||
Prompt: {selectedNode.data.prompt_slug} ({selectedNode.data.prompt_name || 'unbekannt'})
|
// WICHTIG: prompt_slug NICHT löschen, damit er beim Zurückwechseln noch da ist
|
||||||
|
handleNodeUpdate(selectedNode.id, {
|
||||||
|
inline_template: selectedNode.data.inline_template || '' // Aktiviere Inline Mode
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '14px' }}>✏️ Inline-Template erstellen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conditional Rendering: Reference Mode */}
|
||||||
|
{isReferenceMode && (
|
||||||
|
<div className="config-section">
|
||||||
|
<label>Basis-Prompt auswählen</label>
|
||||||
|
<select
|
||||||
|
value={selectedNode.data.prompt_slug || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const promptSlug = e.target.value
|
||||||
|
const selectedPrompt = availablePrompts.find(p => p.slug === promptSlug)
|
||||||
|
handleNodeUpdate(selectedNode.id, {
|
||||||
|
prompt_slug: promptSlug,
|
||||||
|
prompt_name: selectedPrompt?.name || null,
|
||||||
|
inline_template: null
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text1)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">-- Basis-Prompt wählen --</option>
|
||||||
|
{availablePrompts.map(prompt => (
|
||||||
|
<option key={prompt.id} value={prompt.slug}>
|
||||||
|
{prompt.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedNode.data.prompt_slug && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
Prompt: {selectedNode.data.prompt_slug} ({selectedNode.data.prompt_name || 'unbekannt'})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
|
{/* Conditional Rendering: Inline Mode */}
|
||||||
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
|
{isInlineMode && (
|
||||||
</>
|
<InlineTemplateEditor
|
||||||
)}
|
value={selectedNode.data.inline_template || ''}
|
||||||
|
onChange={(template) => handleNodeUpdate(selectedNode.id, {
|
||||||
|
inline_template: template,
|
||||||
|
prompt_slug: null
|
||||||
|
})}
|
||||||
|
onPlaceholderPick={() => {
|
||||||
|
setPlaceholderPickerTarget('inline')
|
||||||
|
setShowPlaceholderPicker(true)
|
||||||
|
}}
|
||||||
|
textareaRef={inlineTemplateTextareaRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
|
||||||
|
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{selectedNode.type === 'logic' && (
|
{selectedNode.type === 'logic' && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -517,7 +646,10 @@ export default function WorkflowEditorPage() {
|
||||||
<EndNodeConfig
|
<EndNodeConfig
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
onChange={handleNodeUpdate}
|
onChange={handleNodeUpdate}
|
||||||
onOpenPlaceholderPicker={() => setShowPlaceholderPicker(true)}
|
onOpenPlaceholderPicker={() => {
|
||||||
|
setPlaceholderPickerTarget('end')
|
||||||
|
setShowPlaceholderPicker(true)
|
||||||
|
}}
|
||||||
textareaRef={endNodeTextareaRef}
|
textareaRef={endNodeTextareaRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -525,36 +657,7 @@ export default function WorkflowEditorPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Validation Panel */}
|
{/* Validation Panel - REMOVED (moved to config panel header) */}
|
||||||
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
|
|
||||||
<div className="validation-panel">
|
|
||||||
{validationErrors.map((err, i) => (
|
|
||||||
<div key={i} className="validation-error" onClick={() => {
|
|
||||||
if (err.nodeId) {
|
|
||||||
setSelectedNodeId(err.nodeId)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
❌ {err.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{validationWarnings.map((warn, i) => (
|
|
||||||
<div key={i} className="validation-warning" onClick={() => {
|
|
||||||
if (warn.nodeId) {
|
|
||||||
setSelectedNodeId(warn.nodeId)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
⚠️ {warn.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{validationErrors.length === 0 && validationWarnings.length > 0 && (
|
|
||||||
<div className="validation-success">
|
|
||||||
✅ Workflow ist valide ({validationWarnings.length} Warnungen)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Execution Result Viewer */}
|
{/* Execution Result Viewer */}
|
||||||
{executionResult && (
|
{executionResult && (
|
||||||
|
|
@ -568,10 +671,33 @@ export default function WorkflowEditorPage() {
|
||||||
{showPlaceholderPicker && (
|
{showPlaceholderPicker && (
|
||||||
<PlaceholderPicker
|
<PlaceholderPicker
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
currentNodeId={selectedNode?.id}
|
||||||
onSelect={handlePlaceholderSelect}
|
onSelect={handlePlaceholderSelect}
|
||||||
onClose={() => setShowPlaceholderPicker(false)}
|
onClose={() => setShowPlaceholderPicker(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
duration={toast.duration || 3000}
|
||||||
|
onClose={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Dialog */}
|
||||||
|
{confirmDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
message={confirmDialog.message}
|
||||||
|
onConfirm={confirmDialog.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
confirmText={confirmDialog.confirmText}
|
||||||
|
cancelText={confirmDialog.cancelText}
|
||||||
|
type={confirmDialog.type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
/* ── Sidebar (Node Palette) ─────────────────────────────────────────────── */
|
/* ── Sidebar (Node Palette) ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.workflow-sidebar {
|
.workflow-sidebar {
|
||||||
width: 250px;
|
width: 220px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
@ -268,7 +268,7 @@
|
||||||
/* ── Config Panel ────────────────────────────────────────────────────────── */
|
/* ── Config Panel ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.workflow-config-panel {
|
.workflow-config-panel {
|
||||||
width: 400px;
|
width: 520px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
@ -453,7 +453,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-config-panel {
|
.workflow-config-panel {
|
||||||
width: 350px;
|
width: 450px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export function serializeToWorkflowGraph(nodes, edges, metadata = {}) {
|
||||||
// Type-spezifische Felder
|
// Type-spezifische Felder
|
||||||
...(node.type === 'analysis' && {
|
...(node.type === 'analysis' && {
|
||||||
prompt_slug: node.data.prompt_slug || null,
|
prompt_slug: node.data.prompt_slug || null,
|
||||||
|
inline_template: node.data.inline_template || null, // Part 3: Inline Prompts
|
||||||
prompt_name: node.data.prompt_name || null,
|
prompt_name: node.data.prompt_name || null,
|
||||||
question_augmentations: node.data.questions || [], // Backend erwartet question_augmentations
|
question_augmentations: node.data.questions || [], // Backend erwartet question_augmentations
|
||||||
fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
|
fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
|
||||||
|
|
@ -84,6 +85,7 @@ export function deserializeFromWorkflowGraph(jsonbData) {
|
||||||
|
|
||||||
...(node.type === 'analysis' && {
|
...(node.type === 'analysis' && {
|
||||||
prompt_slug: node.prompt_slug || node.prompt_id || null, // Fallback für alte Workflows mit prompt_id
|
prompt_slug: node.prompt_slug || node.prompt_id || null, // Fallback für alte Workflows mit prompt_id
|
||||||
|
inline_template: node.inline_template || null, // Part 3: Inline Prompts
|
||||||
prompt_name: node.prompt_name || null, // Falls vom Backend mitgeliefert
|
prompt_name: node.prompt_name || null, // Falls vom Backend mitgeliefert
|
||||||
questions: node.question_augmentations || node.questions || [], // Backend sendet question_augmentations
|
questions: node.question_augmentations || node.questions || [], // Backend sendet question_augmentations
|
||||||
fallback_strategy: node.fallback_strategy || 'conservative_skip'
|
fallback_strategy: node.fallback_strategy || 'conservative_skip'
|
||||||
|
|
|
||||||
|
|
@ -86,17 +86,29 @@ function validateLogic(nodes, edges, errors, warnings) {
|
||||||
// Analysis Nodes
|
// Analysis Nodes
|
||||||
if (node.type === 'analysis') {
|
if (node.type === 'analysis') {
|
||||||
const questions = node.data.questions || []
|
const questions = node.data.questions || []
|
||||||
|
const hasPromptSlug = node.data.prompt_slug != null && node.data.prompt_slug !== ''
|
||||||
|
const hasInlineTemplate = node.data.inline_template != null && node.data.inline_template.trim() !== ''
|
||||||
|
|
||||||
// Prompt ausgewählt?
|
// Part 3: Validation - Entweder prompt_slug ODER inline_template
|
||||||
if (!node.data.prompt_slug) {
|
if (!hasPromptSlug && !hasInlineTemplate) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: 'config',
|
type: 'config',
|
||||||
message: `Analysis-Node "${node.data.label}" hat keinen Prompt`,
|
message: `Analysis-Node "${node.data.label}" benötigt entweder Basis-Prompt oder Inline-Template`,
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
severity: 'error'
|
severity: 'error'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warning wenn beide gesetzt (sollte nicht passieren, aber zur Sicherheit)
|
||||||
|
if (hasPromptSlug && hasInlineTemplate) {
|
||||||
|
warnings.push({
|
||||||
|
type: 'config',
|
||||||
|
message: `Analysis-Node "${node.data.label}" hat sowohl Basis-Prompt als auch Inline-Template - Inline hat Vorrang`,
|
||||||
|
nodeId: node.id,
|
||||||
|
severity: 'warning'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Fragen validieren
|
// Fragen validieren
|
||||||
questions.forEach((q, idx) => {
|
questions.forEach((q, idx) => {
|
||||||
if (!q.question?.trim()) {
|
if (!q.question?.trim()) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user