Zum Inhalt

Dash UI Architektur

Die Benutzeroberfläche von MarketingGPT basiert auf Plotly Dash, einem Python-Framework für interaktive Web-Apps. Diese Dokumentation erklärt die Kernarchitektur und Best Practices für die Entwicklung neuer Seiten und Features.


1. Multi-Page Architektur

MarketingGPT nutzt Dash's built-in Multi-Page System, um 15+ verschiedene Funktionalitäten zu organisieren. Jede Seite ist eine eigenständige Python-Datei, die sich selbst registriert.

App-Initialisierung

In marketinggpt/app.py wird die Dash-App mit Multi-Page Support initialisiert:

app = dash.Dash(
    external_stylesheets=[dbc.themes.BOOTSTRAP, FONT_AWESOME],
    use_pages=True,                           # Auto-Discovery von Pages
    prevent_initial_callbacks="initial_duplicate",
    server=server
)

Wichtig: use_pages=True aktiviert Dash's automatische Page-Discovery. Alle Dateien im pages/ Ordner werden automatisch als eigene Seiten registriert.

Neue Page registrieren

Um eine neue Seite hinzuzufügen:

  1. Erstelle eine neue Datei in marketinggpt/pages/meine_seite.py
  2. Registriere die Seite mit dash.register_page()
  3. Definiere das Layout
  4. (Optional) Registriere Flask Routes für Backend-APIs

Beispiel:

# marketinggpt/pages/meine_seite.py
import dash
from dash import html, callback
from dash.dependencies import Input, Output

# Registriere die Seite — wird automatisch unter /meine_seite geroutet
dash.register_page(__name__)

layout = html.Div([
    html.H1("Meine neue Seite"),
    html.Button("Klick mich", id="my-button"),
    html.Div(id="output"),
])

@callback(
    Output("output", "children"),
    Input("my-button", "n_clicks")
)
def update_output(n_clicks):
    return f"Du hast {n_clicks or 0}x geklickt"

def setup_routes(router):
    """Optional: Backend Flask Routes"""
    @router.route('/meine_api', methods=['POST'])
    def meine_api():
        return {"success": True}, 200

Routing & URL-Prefix

Dash generiert automatisch URLs basierend auf dem Dateinamen:

Dateiname URL
pages/home.py / (Special Case)
pages/generation.py /generation
pages/editor.py /editor
pages/library_management.py /library_management

Wichtig: Verwende in Links immer den get_prefix() Helper, um URL-Prefix Handling zu berücksichtigen:

from pages.home import get_prefix

href = get_prefix() + "/generation"  # Funktioniert auch mit URL-Prefix

Übersicht: Multi-Page Flow

graph LR
    A["app.py<br/>use_pages=True"] --> B["Auto-Discovery<br/>pages/ Ordner"]
    B --> C["dash.register_page<br/>in jeder Page"]
    C --> D["URL Routing<br/>/generation, /editor, ..."]
    D --> E["Sidebar Navigation<br/>Dynamic Filtering"]
    E --> F["User klickt Link<br/>Page lädt"]
    F --> G["Layout + Callbacks<br/>werden ausgeführt"]

2. Role-Based Access Control (RBAC)

MarketingGPT implementiert rollenbasierte Zugriffskontrolle, um nur berechtigte User bestimmte Seiten sehen können.

RBAC Config in app.py

In app.py gibt es ein pages Dictionary, das alle verfügbaren Seiten und ihre Berechtigungen definiert:

pages = {
    "home": {"url": "/", "name": "Home", "allowed_roles": ["all"]},
    "search_filter": {
        "url": "/search_filter",
        "name": "Search by Smart Filters",
        "allowed_roles": ["admin", "author", "sales", "engineering", "stakeholder"],
    },
    "discover_with_ai": {
        "url": "/discover_with_ai",
        "name": "Discover the Database with AI",
        "allowed_roles": ["admin"],  # Nur Admins
    },
    # ... weitere Seiten
}

RBAC Enforcement: has_page_access()

Die Funktion has_page_access() in python_functions/energyai_elements.py prüft ob ein User auf eine Seite Zugriff hat:

def has_page_access(page, username):
    """
    Prüft ob ein User auf eine Seite zugreifen darf.

    - Admins (in ADMIN_USERS) haben Zugriff auf alles
    - Normale User: Rollen-Check
    """
    if username in ADMIN_USERS:
        return True  # Admins: All Access

    page_roles = page.get("allowed_roles", [])
    user_roles = STREAMS["USERS"].get(username, {}).get("roles", [])

    return any(role in page_roles for role in user_roles)

Die Sidebar zeigt nur Seiten an, auf die der User Zugriff hat. In app.py:

visible_pages = [p for p in pages.values() if has_page_access(p, USERNAME)]

Neue Seite mit RBAC hinzufügen

  1. Füge Seite zum pages Dictionary in app.py hinzu:

    "meine_admin_seite": {
        "url": "/meine_admin_seite",
        "name": "Nur für Admins",
        "allowed_roles": ["admin"],
    },
    

  2. Erstelle die Page-Datei marketinggpt/pages/meine_admin_seite.py (siehe Multi-Page Architektur)

  3. Optional: Sicherheits-Check in Backend (siehe oben)


3. Callbacks & State Management Patterns

Dash verwendet Callbacks um interaktive Verhaltensweisen zu definieren. In MarketingGPT gibt es zwei verschiedene Callback-Patterns, je nachdem wie komplex die UI ist.

Pattern A: Client-Side Callbacks mit iFrames

Wo verwendet: generation.py, translate.py, review.py, paraphrase.py, search_filter.py (~60% der Seiten)

Wann sinnvoll:

  • Komplexe, dynamische UIs (Editoren, Drag-Drop, Rich Text)
  • Streaming Responses (Echtzeit-Updates)
  • Viel JavaScript für Interaktionen

Struktur:

# marketinggpt/pages/generation.py
from dash import html

layout = html.Div([
    html.Iframe(
        id="generationIframe",
        src='flask/generation/generation.html',
        style={'minWidth': '100%', 'height': '90vh', 'border': 'none'},
    ),
])

# Client-Side Callback: Resize iFrame bei Route-Change
this_app.clientside_callback(
    """function(pathname) {
        iFrameResize({ log: false }, '#generationIframe');
        return window.dash_clientside.no_update;
    }""",
    Output('generationIframe', 'src'),
    Input('url', 'pathname'),
    prevent_initial_call=False
)

Backend (Flask Route):

Die eigentliche UI-Logik lebt in statischen HTML/JavaScript Files (flask/generation/generation.html). Diese kommunizieren mit Python via Flask Routes:

# In marketinggpt/pages/generation.py
def setup_routes(router):
    @router.route('/get_generation_prompt', methods=['POST'])
    def get_generation_prompt():
        # Backend: Prompt aufbauen, LLM aufrufen, Streaming starten
        connectionID = hashlib.sha256(str(time.time()).encode()).hexdigest()
        start_stream_oai(connectionID, prompt, systemMessage, deployment_name)
        return jsonify({'CID': connectionID}), 200

    @router.route('/poll_stream/<connection_id>', methods=['GET'])
    def poll_stream(connection_id):
        # Frontend pollt diesen Endpoint für Streaming Updates
        if connection_id in STREAMS:
            return jsonify({"data": STREAMS[connection_id]}), 200
        return jsonify({"data": ""}), 200

Frontend (JavaScript in iFrame):

// flask/generation/generation.html
fetch('/api/get_generation_prompt', {
    method: 'POST',
    body: JSON.stringify({prompt: userInput})
})
.then(r => r.json())
.then(data => {
    const connectionID = data.CID;
    // Poll für Streaming Updates
    pollStream(connectionID);
});

function pollStream(cid) {
    fetch(`/api/poll_stream/${cid}`)
        .then(r => r.json())
        .then(data => {
            document.getElementById('output').textContent += data.data;
            if (!data.finished) pollStream(cid);
        });
}

Pattern B: Server-Side Callbacks

Wo verwendet: home.py, library_management.py, global_http.py

Wann sinnvoll: - Einfache UIs (Tabellen, Buttons, Forms) - State Management in Python - Keine Streaming-Anforderungen

Basic Callback (Input → Output)

# marketinggpt/pages/home.py
from dash import callback

@callback(
    Output("homeContent", "children"),
    Input("expanded_tile", "data"),
)
def update_home_content(expanded_id):
    # Wird ausgelöst wenn "expanded_tile" sich ändert
    return build_home_content(expanded_id)

Callback mit State (Input + State → Output)

@callback(
    Output("output", "children"),
    Input("submit_button", "n_clicks"),
    State("input_field", "value"),  # State wird gelesen aber triggert nicht
    prevent_initial_call=True,
)
def handle_submit(n_clicks, input_value):
    # Wird nur ausgelöst wenn Button geklickt
    # input_value wird gelesen aber ändert sich nicht
    return f"Du hast eingegeben: {input_value}"

Pattern-Matched Callbacks (dash.ALL)

Für dynamische Component-Listen verwendest du Pattern-Matched Callbacks:

# marketinggpt/pages/home.py
@callback(
    Output("expanded_tile", "data"),
    [Input({"type": "tilecard-btn", "index": dash.ALL}, "n_clicks")],
    [State("expanded_tile", "data")],
    prevent_initial_call=True,
)
def expand_tile(n_clicks_list, expanded_id):
    ctx = dash.callback_context

    if not ctx.triggered:
        return expanded_id

    # Extrahiere welcher Button geklickt wurde
    triggered_id = json.loads(ctx.triggered[0]["prop_id"].split(".")[0])
    tile_id = triggered_id["index"]

    # Toggle: Wenn gleiche Tile nochmal geklickt, zusammenklappen
    return None if expanded_id == tile_id else tile_id

Wie die Buttons erzeugt werden:

def TileCard(tile, expanded_id):
    return html.Div([
        html.Button(
            tile["title"],
            id={"type": "tilecard-btn", "index": tile["id"]},
            n_clicks=0,
        ),
        # Content wenn expanded
        html.Div(...) if expanded_id == tile["id"] else None
    ])

Multiple Outputs mit allow_duplicate

Manche Callbacks müssen mehrere Outputs aktualisieren:

# marketinggpt/pages/library_management.py
@callback(
    Output("library", "data", allow_duplicate=True),  # Primary output
    [Output(f"FLAG_LIBRARY_{lib}", "data") for lib in LIBRARY_LIST],  # Extra outputs
    Input("library-saveChanges", "n_clicks"),
    State("library", "data"),
    prevent_initial_call=True,
)
def update_library(n_clicks, table_data):
    # Aktualisiere Haupt-Store und triggere andere Callbacks
    new_lib_data = process_library(table_data)
    library_flags = [1 for _ in LIBRARY_LIST]  # Trigger andere Callbacks

    return [new_lib_data] + library_flags

4. State Management

State in Dash ist verteilt über 3 Layer.

Layer 1: dcc.Store (Lokal, Single-Page)

# marketinggpt/app.py
dcc.Store(id="expanded_tile", data=None),     # Welche Home-Tile ist expanded?
dcc.Store(id="side_click", data=0),          # Ist Sidebar offen oder zugeklappt?

Wann verwenden:

  • Einfache, primitive Werte (String, Number, Boolean)
  • Nur auf einer Seite benötigt
  • Kleine Datenmengen

Beispiel:

@callback(
    Output("expanded_tile", "data"),
    Input({"type": "tilecard-btn", "index": dash.ALL}, "n_clicks"),
    State("expanded_tile", "data"),
)
def expand_tile(n_clicks, expanded_id):
    return new_expanded_id  # Wird in dcc.Store gespeichert

Layer 2: session_storage (Cross-Page)

# marketinggpt/app.py
dcc.Store(id="session_storage", data={
    "document_translate": {},
    "document_review": {},
    "create_draft": {},
    "review_draft": {},
})

Wann verwenden:

  • State zwischen mehreren Seiten teilen
  • Beispiel: User wählt Dokument auf Search-Seite → will es auf Review-Seite reviewen
  • Session-basiert (verschwindet nach Reload)

Beispiel:

# Auf search_filter.py: Speichere ausgewähltes Dokument
@callback(
    Output("session_storage", "data"),
    Input("select-document-btn", "n_clicks"),
    State("session_storage", "data"),
    State("document-id", "value"),
)
def select_document(n_clicks, session_data, doc_id):
    session_data["document_translate"] = {"id": doc_id}
    return session_data

# Auf review.py: Lese ausgewähltes Dokument
@callback(
    Output("review-content", "children"),
    Input("url", "pathname"),  # Wenn auf diese Seite navigiert
    State("session_storage", "data"),
)
def load_review(pathname, session_data):
    doc = session_data.get("document_review", {})
    return display_document(doc)

Layer 3: FLAG_* Stores (Anti-Pattern — nur als Workaround)

# marketinggpt/app.py
dcc.Store(id="FLAG_LIBRARY_MANAGER", data=0),
dcc.Store(id="FLAG_SEARCH_FILTER", data=0),

Wann verwenden: Nur wenn du andere Callbacks zwingen willst sich zu neu-ausführen.

Beispiel (Anti-Pattern):

# Callback A: Aktualisiere Daten
@callback(
    Output("data", "data"),
    Output("FLAG_LIBRARY", "data"),  # Increment to trigger other callbacks
    Input("save-btn", "n_clicks"),
)
def save_data(n_clicks):
    return new_data, (n_clicks or 0)  # Ändere FLAG um andere Callbacks zu triggern

# Callback B: Re-execute wenn FLAG sich ändert
@callback(
    Output("table", "children"),
    Input("FLAG_LIBRARY", "data"),  # Wird triggered wenn FLAG sich ändert
)
def refresh_table(flag):
    return load_table_from_database()


5. Navigation & Komponenten

Home Dashboard: Tile-Based Navigation

Die Home-Seite (pages/home.py) nutzt eine Tile-basierte Navigationt, um die Funktionalitäten zu organisieren.

TILE_CONFIG Definition:

# marketinggpt/pages/home.py
TILE_CONFIG = [
    {
        "id": "search",
        "title": "Product & Document Search",
        "icon": "fas fa-search",
        "allowed_roles": ["admin", "author", "sales", "engineering", "stakeholder"],
        "subpages": [
            {
                "label": "Search by Smart Filters",
                "route": "/search_filter",
                "color": "#f1c40f",
                "allowed_roles": ["admin", "author", "sales", "engineering", "stakeholder"],
            },
            {
                "label": "Search by Document",
                "route": "/search_documents",
                "color": "#e74c3c",
                "allowed_roles": ["admin", "author", "sales", "engineering", "stakeholder"],
            },
        ],
    },
    # ... weitere Tiles
]

Funktionsweise:

graph TD
    A["User öffnet Home"] --> B["TILE_CONFIG laden"]
    B --> C["Pro Tile: Subpages nach Role filtern"]
    C --> D["TileCard rendern mit Buttons"]
    D --> E["User klickt Tile-Button"]
    E --> F["Tile expanded/collapsed"]
    F --> G["Subpage-Links werden sichtbar"]
    G --> H["User klickt Subpage-Link"]
    H --> I["Navigiere zu Seite"]

TileCard Component

# marketinggpt/pages/home.py
def TileCard(tile, expanded_id, username):
    # Filter: Zeige nur Subpages auf die User Zugriff hat
    visible_subpages = [
        sp for sp in tile["subpages"] 
        if has_page_access(sp, username)
    ]

    if not visible_subpages:
        return None  # Ganze Tile verstecken wenn keine Subpages sichtbar

    is_expanded = expanded_id == tile["id"]

    return html.Div([
        # Tile-Button
        html.Button(
            [
                html.I(className=tile["icon"]),
                html.Span(tile["title"]),
            ],
            id={"type": "tilecard-btn", "index": tile["id"]},
            className="tile-button",
        ),

        # Subpages (nur wenn expanded)
        html.Div([
            SubActionButton(sp["label"], sp["route"], sp["color"])
            for sp in visible_subpages
        ], className="subpages") if is_expanded else None,
    ])

SubActionButton Component

Wiederverwendbarer Component für Subpage-Links mit Farben:

# marketinggpt/pages/home.py
def SubActionButton(label, route, color, btn_id=None):
    return html.A([
        html.Span(className="subaction-color", style={"background": color}),
        html.Span(label),
    ],
    href=get_prefix() + route,
    className="subaction-btn",
    )

Die Sidebar zeigt alle verfügbaren Seiten und erlaubt Toggle zwischen Icon-Only und Full-Text View.

Sidebar Toggle Callback app.py:

@app.callback(
    [
        Output("sidebar", "style"),
        Output("page-content", "style"),
    ],
    [Input("sidebar-toggle", "n_clicks")],
    [State("side_click", "data")],
)
def toggle_sidebar(n, nclick):
    """Toggle zwischen collapsed (50px) und expanded (200px) Sidebar"""
    if n:
        if nclick:
            # Gerade expanded → collapse
            return [SIDEBAR_HIDDEN, CONTENT_STYLE_COLLAPSED]
        else:
            # Gerade collapsed → expand
            return [SIDEBAR_STYLE, CONTENT_STYLE]

    return [SIDEBAR_STYLE, CONTENT_STYLE]

Sidebar Aufbau:

# marketinggpt/app.py
sidebar = html.Div([
    # Toggle Button
    html.Button(
        html.I(className="fas fa-bars"),
        id="sidebar-toggle",
        n_clicks=0,
    ),

    # Navigations-Links
    html.Div(
        [
            html.A(
                page["name"],
                href=prefix + page["url"],
                className="nav-link",
            )
            for page in visible_pages
        ],
        id="sidebarNames",
    ),

    # Store für Toggle-State
    dcc.Store(id="side_click", data=0),
])

Neue Tile hinzufügen

Um eine neue Kategorie auf der Home-Seite hinzuzufügen:

  1. Füge Tile zu TILE_CONFIG hinzu pages/home.py:

    {
        "id": "meine_kategorie",
        "title": "Meine Kategorie",
        "icon": "fas fa-star",  # Font Awesome Icon
        "allowed_roles": ["admin", "author"],
        "subpages": [
            {
                "label": "Subseite 1",
                "route": "/meine_seite",
                "color": "#3498db",
                "allowed_roles": ["admin", "author"],
            },
        ],
    }
    

  2. Erstelle die neue Seite (siehe Multi-Page Architektur oben)

  3. Speichern & Testen