Alle Artikel

Generative UI in Flutter: Ein KI-Chat, der Bausteine statt Text liefert

Flutter Generative UI Tool Use

Ein KI-Chat, der nur Text zurückgibt, ist in einer App schnell gebaut und schnell nutzlos. Die Arbeit fängt nach der Antwort an: lesen, abtippen, in den richtigen Bildschirm übertragen. Für die von VorFina konzipierte App für Versicherungsmakler haben wir den Chat deshalb so gebaut, dass er mit Oberflächen-Bausteinen antwortet. Dieser Beitrag zeigt, wie das in Flutter aussieht.

Erste Fassung: E-Mail-Vorschau als Text mit Link ins Mailprogramm
Heutige Fassung: Aktionskarte mit Feldern für Empfänger, Betreff und Text, darunter Verwerfen und Senden
Links die erste Fassung mit Text und Link ins Mailprogramm, rechts die heutige mit Aktionskarte. Gleiches Modell, anderer Unterbau.

Kurz gesagt: Das Sprachmodell zeichnet keine Oberfläche. Das Backend entscheidet pro Werkzeug, welcher Baustein die Antwort trägt, und die Flutter-App hat für jeden Baustein genau ein Widget. Die Typen sind Sealed Classes, der Dispatch ist ein Switch ohne Default, und was die App-Version nicht kennt, landet in einem sichtbaren Fallback statt in einem Absturz. Eine Galerie aller Bausteine gehört zum Projekt dazu.

Warum Text als Antwort nicht reicht

Die erste Fassung des Chats antwortete mit Markdown. Wollte der Makler eine E-Mail schicken, bekam er eine Vorschau im Fließtext und einen Link, der das Mailprogramm öffnet. Technisch korrekt, für den Menschen ein Umweg: noch einmal lesen, anpassen, selbst absenden.

Die Regel, die seitdem gilt: Lesende Werkzeuge darf der Chat selbstständig ausführen. Vor jedem schreibenden Werkzeug holt er eine Bestätigung, und diese Bestätigung muss sich in Ruhe lesen lassen. Eine Textliste aus Feldname, Doppelpunkt und Wert ist keine Bestätigung, sondern ein Reflex. Warum uns das so wichtig ist, steht im Beitrag Ein KI-Chat, der nur redet, oder einer, der etwas erledigt auf unserer Schwesterseite. Hier geht es um die Flutter-Seite.

Der Vertrag zwischen Backend und App

Das Backend spricht mit dem Modell und führt die Werkzeuge aus. In die App kommt pro Antwort eine Liste von Blöcken: Text, ein Anzeige-Block, ein Widget-Block, der auf eine Eingabe wartet, oder ein Anhang. Der Anzeige-Block trägt eine typisierte Nutzlast. In Dart ist das eine Sealed Class mit einer Variante pro Backend-Werkzeug:

@freezed
sealed class BlockPayload with _$BlockPayload {
  const factory BlockPayload.table({
    required List<String> headers,
    required List<List<String>> rows,
    String? title,
  }) = TablePayload;

  const factory BlockPayload.confirm({
    required String message,
    @Default(false) bool destructive,
  }) = ConfirmPayload;

  const factory BlockPayload.email({
    required List<String> to,
    required String subject,
    required String body,
    // ...
  }) = EmailPayload;

  /// A payload type this client version does not know.
  const factory BlockPayload.unknown({
    required String type,
    required Map<String, dynamic> raw,
  }) = UnknownPayload;
}

Elf Varianten gibt es heute: Tabelle, Tabelle mit Sprung in die Kundenakte, Kennzahlen, Balken- und Liniendiagramm, Kreisdiagramm, Download, Bestätigung, Auswahl, Schnellauswahl, Formular und E-Mail-Entwurf. Die zwölfte Variante ist unknown, und sie ist die wichtigste.

Das Parsen wirft nie. Ein unbekannter Typ wird zu UnknownPayload, mit dem Rohdatensatz im Gepäck und einem Logeintrag, damit wir im Monitoring sehen, welche App-Version welchen Baustein nicht kannte. Das Backend darf damit neue Bausteine ausliefern, bevor die App im Store nachgezogen ist. Der Nutzer sieht dann einen Hinweis statt einer leeren Stelle.

Der Baukasten, auf einen Blick

Alle Bausteine des KI-Chats in einer Galerie: Text, Tabelle, Kennzahlen, Diagramme, Download, Auswahl, Bestätigung, Formular, E-Mail-Entwurf, Wartezustand und der Fallback für unbekannte Bausteine
Jeder Baustein, den die App zeichnen kann, gerendert aus den echten Widgets mit Testdaten. Rechts oben steht jeweils die Datei, die den Baustein zeichnet.

Jeder Baustein ist ein eigenes Widget in einer eigenen Datei, und alle sitzen in derselben Karte. Die Karte, die Überschrift in Versalien, die Trennlinie, der umrandete Innenkasten für Kennzahlen und Eingabefelder: das sind vier kleine Widgets, die jeder Baustein wiederverwendet. Dadurch liest sich eine Antwort mit Tabelle, Diagramm und Formular wie eine Oberfläche und nicht wie drei Oberflächen.

Ein Detail, das erst im Gebrauch auffällt: Wenn ein Widget-Block beantwortet wird, wechselt die Karte vom Formular zu einer Zeile mit der gegebenen Antwort. Dieser Wechsel läuft über einen AnimatedSwitcher in einer AnimatedSize, damit die Karten darunter nachrutschen statt zu springen. Ein harter Schnitt an dieser Stelle sieht aus wie ein Fehler.

Ein Switch, der nicht schweigt

Die Zuordnung von Block zu Widget ist ein einziges Widget, BlockHost. Sein build ist ein Switch über die Sealed Classes, ohne Default-Zweig:

Widget _display(BlockPayload payload) => switch (payload) {
  final TablePayload payload => TableBlock(payload: payload),
  final MetricPayload payload => MetricBlock(payload: payload),
  final ChartPayload payload => ChartBlock(payload: payload),
  // ...
  _ => _unsupported('display payload ${payload.runtimeType}'),
};

Der Wildcard-Zweig am Ende fängt nur UnknownPayload. Kommt eine neue Variante in die Sealed Class, meldet der Compiler jede Stelle, die sie noch nicht behandelt. Ein neuer Baustein im Backend ist damit ein Kompilierfehler in der App, nicht eine Lücke, die erst ein Kunde findet. Zur Laufzeit bleibt für ältere App-Versionen der Fallback.

Widget-Blöcke bekommen zusätzlich eine Interrupt-ID und den Zustand submitted. Das Backend hält die Unterhaltung an, bis die App mit dieser ID und dem Wert antwortet. Beim Laden einer alten Unterhaltung kommen die Blöcke bereits als beantwortet zurück, und das Widget zeigt die Antwort statt der Buttons. Dieselbe Darstellung für „gerade beantwortet“ und „vor einer Woche beantwortet“ ist kein Zufall, sondern eine Anforderung.

Bestätigungskarte vor dem Anlegen einer Aufgabe mit Abbrechen und Bestätigen
Antwort als Tabelle: Kunden mit dem Nachnamen Mustermann mit Geburtsdatum und Ort
Links ein Widget-Block, der auf eine Entscheidung wartet. Rechts ein Anzeige-Block: Die Frage war ein Satz, das Backend hat die Tabelle gewählt.

Der Chat selbst: ein angepasstes Toolkit

Die Chat-Ansicht basiert auf dem Flutter AI Toolkit, das wir als Paket im Monorepo führen und erweitert haben. Die wichtigste Ergänzung ist ein blockBuilder: Die Nachricht eines Agenten ist bei uns keine Zeichenkette, sondern eine Liste von Blöcken, und das Toolkit ruft für jeden Block unser BlockHost auf. Die Antwort kommt als Stream vom Backend. Weil die Ansicht bei jedem Stück neu baut, cacht die Nachrichten-View ihr gerendertes Ergebnis pro Revision der Nachricht. Sonst würde jede Tabelle bei jedem Textstück neu entstehen, und eine lange Unterhaltung wird mit jeder Antwort langsamer.

Diagramme kommen aus fl_chart, das Theme aus shadcn_flutter, der Zustand aus Riverpod. Nichts davon ist exotisch. Die Arbeit steckt nicht in der Wahl der Pakete, sondern in dem Vertrag darüber, wer was entscheidet.

Jeden Baustein in jedem Zustand sehen

Ein Baustein, den niemand anschaut, bis ein Kunde ihn auslöst, ist ein Baustein mit einem Fehler. Das Kreisdiagramm mit einer einzigen Sparte, die Tabelle mit acht Spalten, der Balken unter null, das bereits beantwortete Formular: all das muss jemand regelmäßig sehen, ohne dafür das Backend in genau diesen Zustand bringen zu müssen.

In der App gibt es dafür eine Block-Galerie, erreichbar aus den Einstellungen im Debug-Build. Sie rendert feste Testdaten durch die echte Chat-Ansicht, den echten Stil und denselben BlockHost wie im Betrieb. Jeder Eintrag nennt die Datei, die ihn zeichnet, damit ein Baustein, der falsch aussieht, direkt auf seinen Quelltext zeigt. Das Bild oben ist aus denselben Fixtures entstanden.

Widgetbook

Wer so eine Galerie nicht selbst pflegen möchte, nimmt Widgetbook. Es zeigt jedes Widget in jedem Zustand, in Hell und Dunkel, in jeder Gerätegröße, mit Reglern für die Eingaben. Mit Widgetbook Cloud hängt diese Galerie an jedem Pull Request, und man vergleicht die Bausteine Version für Version, bevor etwas im Store landet. In unseren Projekten ist das die größte Hilfe gegen Oberflächen, die nur im Idealfall gut aussehen.

Dazu kommen Widget-Tests pro Baustein. Ein kleiner Harness hostet einen einzelnen Block ohne Chat, ohne Provider und ohne Backend und protokolliert, welche Antwort der Block gemeldet hat. Die Tests prüfen, dass eine Bestätigung beide Buttons zeigt, dass ein beantworteter Block sie nicht mehr zeigt, dass nur Tabellenzeilen mit Referenz antippbar sind und dass ein unbekannter Block einen Hinweis rendert statt zu werfen.

Was wir beim nächsten Mal früher machen würden

  • Eine Tonlage festlegen, bevor der erste Text aus dem Backend kommt. Die Chat-Antworten duzen heute, die Karten und Vorschläge siezen. Das ist kein Flutter-Problem, es fällt nur in Flutter auf.
  • Breite Tabellen von Anfang an als Fall einplanen. Fünf Spalten auf einem Telefon sind seitlich scrollbar, mehr nicht. Das Backend sollte wissen, dass es ein Telefon beliefert.
  • Beschriftungen von Diagrammen in die Legende, nicht unter die Balken. Zusammengesetzte Substantive wie „Bestandsprovision“ überlappen sonst ab dem dritten Balken.

Was das für Ihr Flutter-Team heißt

Wenn Sie einen KI-Chat in eine bestehende App bauen, helfen diese Fragen beim Einstieg:

  1. Wer entscheidet, welcher Baustein eine Antwort trägt? Das Modell, das Backend oder die App. Bei uns ist es das Backend, und die App kennt eine feste Liste.
  2. Was passiert mit einem Baustein, den die App-Version nicht kennt? Absturz, Leerstelle oder Hinweis. Nur das Dritte ist akzeptabel.
  3. Sehen Sie heute jeden Baustein in jedem Zustand, ohne das Backend dafür zu bemühen? Wenn nein: Galerie oder Widgetbook, bevor der nächste Baustein kommt.
  4. Sieht ein beantworteter Block nach dem Neuladen genauso aus wie direkt nach dem Antippen?
  5. Lässt sich der Chat ohne Backend testen? Ein Harness, der einen Block hostet, ist in einer Stunde gebaut und spart Wochen.

Ein KI-Chat ist ein Feature wie jedes andere: Er gehört in die Planung einer neuen Flutter-App, oder er kommt als klar abgegrenzter Baustein in ein MVP, um zu prüfen, ob Ihre Nutzer ihn annehmen. Was KI-Features im Budget ausmachen, steht im Beitrag Was kostet eine Flutter-App?

Wenn Sie einen solchen Chat in Ihrer Flutter-App planen, zeigen wir Ihnen im Erstgespräch gern, wie der Baukasten bei uns aufgebaut ist und welche Bausteine Ihr Fall braucht.

Pan Friebe

Über Pan Friebe

Teil des FriebeDev Teams - vier Geschwister aus Berlin, die gemeinsam Flutter Apps entwickeln. Mit über 300.000 Downloads und Projekten für Unternehmen wie get2Card und BlazeSQL.

Mehr über unser Team →

Bereit für Ihre App?

Lassen Sie uns in einem kostenlosen Erstgespräch über Ihr Projekt sprechen.

Kostenloses Erstgespräch