Blog/Web-Engineering

Streaming-Chat mit KI in Vue und Nuxt: von Anfang an barrierefrei

KI-Chat in Vue oder Nuxt: Fetch-Streams, Abbruch und Retry, sicheres Markdown-Rendering, aria-live-Meldungen, Tastaturnavigation und Reduced Motion.

··11 Min. Lesezeit

  • Vue
  • Nuxt
  • Accessibility
  • Streaming
  • AI chat
Eine Nachrichtenkarte mit blinkendem Cursor, eine gepunktete Linie öffnet sich zu Wellen, daneben ein Haken.

Das Wichtigste in Kürze

  • EventSource ist für einseitige Benachrichtigungen gebaut; ein Chat, der seine Nachrichten per POST schickt und Header braucht, liest den Fetch-Response-Body lieber als Stream.
  • Abbrechen ist eine Nutzerabsicht, kein Fehler: den partialen Antworttext behalten und nach einem Stop-Klick niemals den Zustand rot einfärben.
  • Modell-Ausgabe ist unvertrautes Eingabematerial: ein markdown-Parser, der standardmäßig sicher ist, plus DOMPurify, und nach dem Sanitizing darf niemand mehr den String anfassen.
  • Das Transkript in role=log, flüchtige Zustände in role=status; Antworten ansagen, nicht Tokens.
  • Tastaturbedienung, 24-Pixel-Ziele und prefers-reduced-motion gehören zur gleichen Definition of Done wie der Stream-Parser.

Diesen Artikel anhören

0:000:00

Ein Chatfeld ist das am einfachsten zu demonstrierende LLM-Feature und eines der schwersten, das man gut ausliefert. Die Antwort kommt Token für Token, der Nutzer sieht dem Text beim Wachsen zu, und in diesem Loop sitzen drei getrennte Pflichten: den Stream ehrlich halten, wenn das Netz stört, Modell-Ausgabe davon abhalten, im Browser zu laufen, und die Oberfläche für jemanden benutzbar zu halten, der den Text nie sieht. Ich baue Vue- und Nuxt-Anwendungen für B2B-Kunden in Österreich, und in jedem Chat-Review funktioniert das Streaming und die Barrierefreiheit nicht.

Was folgt, ist eine Reihenfolge zum Bauen, keine Bibliotheks-Bewertung. Wir wählen ein Transportformat, lesen es in einem Vue-Composable mit einem Reader und einem AbortController, modellieren die Verbindung als vier Zustände, sanitieren das Markdown des Modells und erledigen dann den Teil, der sonst weggelassen wird: neue Inhalte für Screenreader ansagen, den Composer per Tastatur bedienbar halten und prefers-reduced-motion respektieren. Die Grundlagen des Streamings aus einer Nuxt-Server-Route stehen in meinem früheren Artikel über LLM-Features in Nuxt; hier geht es tiefer in die Oberfläche um den Stream herum.

Das Transportformat wählen, bevor Sie die Komponente wählen

Zwei Transporte beherrschen Chat-Oberflächen. Das browser-native EventSource spricht Server-Sent Events: der Server antwortet mit dem Content-Type text/event-stream und UTF-8-Nachrichten aus den Feldern event, data, id und retry, und die Wiederverbindung übernimmt der Browser. Diese Resilienz hat in einem Chat zwei scharfe Kanten: die Verbindung ist einseitig, sie trägt also nicht die POST-Anfrage mit Ihrer Nachrichtenhistorie, und die Wiederverbindung ist automatisch. Nach einem Netzwerkfehler versucht es der Browser ohne Rückfrage erneut, was für einen Einmal-Token-Stream genau falsch ist, weil ein stiller Retry eine halb gerenderte Antwort duplizieren kann.

Es gibt auch eine Infrastruktur-Grenze, die man vorher kennen sollte. Über HTTP/1.1 begrenzen Browser offene Verbindungen auf sechs pro Browser und Domain, eine in Chrome und Firefox als won't fix markierte Grenze, während bei HTTP/2 die Zahl simultaner Streams zwischen Browser und Server ausgehandelt wird und standardmäßig bei 100 liegt. Wer noch über HTTP/1.1 terminiert, sitzt mit wenigen offenen Chat-Tabs bereits an der Decke.

AnsatzRichtungAuthWiederverbindungWas Sie selbst behandeln
EventSource (SSE)nur Server zu ClientGET, keine Request-Bodyautomatisch über das retry-Feldden gesamten Sendepfad
fetch plus ReadableStreamRequest-Body und Response-Streamvolle Header und Bodykeine, Sie entscheidenParsing, Abbruch, Fehlerzustände
AI SDK useChatPOST mit UI-Message-Streamkonfigurierbare Header und Bodyvom Transport gehandhabtZustand, Rendering, Barrierefreiheit

Für einen Chat, der eine Nachrichtenliste sendet, einen Authorization-Header braucht und beim Klick auf Stop sofort stoppen muss, ist der Fetch-Response-Body als ReadableStream die ehrliche Wahl, und genau das tut der useChat-Hook des AI SDK auch, inklusive für sein Vue-Paket. Der Rest dieses Artikels baut diese Schleife von Hand, damit Sie, wenn Sie zum Hook greifen, wissen, was er für Sie tut und wo sein Zustandsmodell endet und Ihre Barrierefreiheitsarbeit beginnt.

Einen Fetch-Stream in einem Vue-Composable lesen

Ein Fetch-Response legt seinen Body als ReadableStream offen. Sie sperren ihn mit getReader() und lesen in einer Schleife, oder Sie überspringen den Reader und iterieren die Chunks direkt: die Streams-API implementiert das async-iterable-Protokoll, for await...of über den Response-Body funktioniert also ohne Zutun. MDN verweist auf zwei Details, die in der Produktion zählen: Iterieren sperrt den Stream, sodass ihn niemand anderes konsumieren kann, und ein vorzeitiges Verlassen der Schleife storniert den Stream, wenn Sie nicht preventCancel übergeben. Für die Bytes leiten Sie den Body durch einen TextDecoderStream, die Stream-Form von TextDecoder, die eine Binärkodierung in einen String-Stream umwandelt.

<script setup lang="ts">
import { ref, onBeforeUnmount } from "vue"

type Status = "ready" | "streaming" | "error"
const reply = ref("")
const status = ref<Status>("ready")
let controller: AbortController | null = null

async function send(text: string) {
  controller?.abort()
  controller = new AbortController()
  status.value = "streaming"
  reply.value = ""
  try {
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: text }),
      signal: controller.signal,
    })
    if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
    const reader = res.body
      .pipeThrough(new TextDecoderStream())
      .getReader()
    let leftover = ""
    for (;;) {
      const { done, value } = await reader.read()
      if (done) break
      leftover += value
      const events = leftover.split("\n\n")
      leftover = events.pop() ?? ""
      for (const ev of events) {
        if (ev.startsWith("data: ")) reply.value += ev.slice(6)
      }
    }
    status.value = "ready"
  } catch (err) {
    // a Stop press is not a failure
    status.value = (err as Error).name === "AbortError" ? "ready" : "error"
  }
}

onBeforeUnmount(() => controller?.abort())
</script>

Zwei Zeilen in dieser Schleife verdienen einen Kommentar. Der leftover-Puffer existiert, weil Netzwerk-Chunks nicht an Ihrem Protokoll ausgerichtet sind: ein einzelnes data-Ereignis kann über zwei Reads aufgeteilt sein, also verarbeiten Sie nur vollständige, durch Leerzeilen getrennte Ereignisse und behalten den Rest für die nächste Iteration. Und der catch-Block behandelt einen AbortError als normales Ende, denn ein Klick auf Stop ist eine Nutzerabsicht und kein Fehler. Alles andere, auch ein vor dem Streamstart festgestellter Nicht-2xx-Status, bringt den Zustand in error, wo die Oberfläche einen Retry anbieten kann.

Abbrechen ist ein Feature, kein Fehler

AbortController ist das Standardwerkzeug dafür: seine abort()-Methode bricht Fetch-Anfragen, das Konsumieren von Response-Bodies und Ströme ab. Verdrahten Sie einen Controller pro Gesprächsrunde, brechen Sie die vorherige Anfrage ab, bevor die nächste startet, und brechen Sie beim Unmount ab, damit ein Wegnavigieren während langer Generierungen nicht den Server in einen toten Tab streamen lässt. Der heikle Teil ist die Oberfläche darum: die bereits eingetroffene Teilantwort ist gültiger Text, also behalten Sie sie im Transkript, markieren Sie die Nachricht als gestoppt und färben Sie den Zustand nie rot.

// stop is user intent: abort and keep whatever arrived
function stop() {
  controller?.abort()
  status.value = "ready"
}

// retry resends the last user message, not the last wire payload
async function retry() {
  const last = messages.value.at(-1)
  if (last?.role === "user") await send(last.text)
}

Retry gehört als normaler Button in den Ablauf, der beim Erscheinen den Fokus übernimmt, nicht als versteckte Geste. Das AI SDK bietet genau dieses Paar: stop bricht die Fetch-Anfrage ab, regenerate lässt das Modell die letzte Nachricht neu verarbeiten, und status steuert, welches davon aktiv ist. Wenn etwas wirklich fehlschlägt, zeigen Sie eine kurze generische Meldung: die AI-SDK-Dokumentation empfiehlt einen schlichten etwas-ist-schief-Text statt des rohen Fehlers, damit Serverdetails aus einer öffentlichen Oberfläche draußen bleiben, während der echte Fehler in Ihren Logs landet, wo Sie handeln können.

Lebenszyklus eines Chat-StreamsDer Composer sendet eine POST-Anfrage, der Server streamt Token-Chunks zurück, der Client hängt sie an und sanitisiert sie, und der Stream endet im Done-Zustand; der Stop-Pfad bricht den Fetch ab, der Fehlerpfad bietet einen Retry.ComposersendenPOST/api/chatStreamToken-ChunksRendernsanitierenFertigready-ZustandStopabort()Fehlerretry
Zwei Ausgänge zählen: Stop muss in der Zustandsmaschine nicht von Erfolg zu unterscheiden sein, während ein Netzwerkfehler auf einer Retry-Aktion landen muss, nicht auf einem Stacktrace.

Die Verbindung als vier Zustände modellieren

Leiten Sie jede sichtbare Steuerung aus einem einzigen Statuswert ab. Der useChat-Hook des AI SDK liefert einen Status mit vier Werten: submitted bedeutet, die Anfrage ist unterwegs und der Stream noch nicht gestartet, streaming bedeutet, Chunks treffen ein, ready bedeutet, die Antwort ist fertig und eine neue Nachricht kann gesendet werden, und error bedeutet, die Anfrage ist fehlgeschlagen. Ein gesendeter Button, der außerhalb von ready deaktiviert ist, ein Stop-Button nur während submitted oder streaming und Retry nur im Fehlerzustand ist eine Zustandsmaschine, die Sie ohne Screenreader testen können, und es ist dieselbe Maschine, die assistive Technik über Ihre Statusregion meldet.

Throttling gehört ins Zustandsmodell, nicht in ein Performance-Backlog. useChat dokumentiert eine throttle-Option für Vue, die reaktive Nachrichten-Updates bündelt (die Dokumentation nennt 50 Millisekunden), während Streamverarbeitung und Event-Callbacks sofort bleiben, und garantiert, dass die letzten Nachrichten vor dem Erreichen eines Endzustands veröffentlicht werden. Ohne sie mutiert ein schnelles Modell Ihr reaktives Array Dutzende Male pro Sekunde und jeder Consumer rendert mit.

Vue hilft auf der Render-Seite: Bei Mutation reaktiver Zustände werden DOM-Updates nicht synchron angewandt, sondern bis zum nächsten Tick gepuffert, sodass mehrere Chunk-Schreibungen in einem Tick zu einem einzigen Patch zusammenfallen. Messen Sie das Transkript-DOM nicht im selben Tick, in dem Sie einen Chunk angehängt haben; warten Sie zuerst auf nextTick(). Hier treffen auch die Barrierefreiheits- und Performance-Budgets aufeinander, denn weniger Renders pro Tastendruck halten das Interaction Next Paint der ganzen Seite ehrlich, worum es in der Core-Web-Vitals-Orientierung für Nuxt-Seiten geht.

Markdown rendern, ohne das DOM zu übergeben

Modell-Ausgabe ist von Nutzern beeinflusste Eingabe. Ein Prompt kann das Modell bitten, ein script-Tag, ein iframe oder ein javascript-Link auszugeben, und wer das Gespräch steuern kann, kann es wirklich versuchen, also ist das dasselbe Bedrohungsmodell wie bei jedem anderen unvertrauten HTML. Die sichere Kombination ist ein markdown-Parser, der standardmäßig sicher ist, plus ein Sanitizer auf seinem Ausgang. Genau das bewirbt markdown-it: Es folgt CommonMark und ist standardmäßig sicher, rohes HTML im Markdown wird also maskiert statt durchgereicht, sofern Sie nicht ausdrücklich zustimmen. DOMPurify räumt dann auf, was der Parser produziert hat.

import MarkdownIt from "markdown-it"
import DOMPurify from "dompurify"

// html bleibt false: rohes HTML in der Modell-Ausgabe wird maskiert
const md = new MarkdownIt({ linkify: true })

export function renderAssistantMarkdown(src: string): string {
  const html = md.render(src)
  return DOMPurify.sanitize(html, {
    USE_PROFILES: { html: true }, // Antworten brauchen weder SVG noch MathML
    ADD_ATTR: ["target"],
  })
}

Halten Sie die Allow-Liste schmal und die Pipeline einrichtig: Markdown rein, sanitisiertes HTML raus, und erst dann mit v-html gerendert. Links erhalten ihre target- und rel-Attribute von Ihrem Renderer, nie vom Modell, und alles, was wie ein Tool-Aufruf oder eine Produktkarte aussieht, wird von einer Vue-Komponente gezeichnet, die Sie kontrollieren. Auch ein Screenreader trifft diese Inhalte, also halten Sie die generierte Markups semantisch: echte Überschriften, echte Listen, echte Code-Elemente, denn assistive Technik navigiert genau über diese Strukturen.

Die Antwort ansagen, nicht jeden Token

Jetzt der Teil, den die meisten Implementierungen auslassen. WCAG 2.2 wurde am 5. Oktober 2023 als W3C-Empfehlung veröffentlicht, und das Erfolgskriterium 4.1.3 Status Messages auf Stufe AA verlangt, dass Statusmeldungen über Rollen oder Eigenschaften programmatisch bestimmbar sind, damit assistive Technik sie präsentieren kann, ohne dass das Element den Fokus erhält. Die WAI-Techniken nennen role=status für Ergebnisse und Wartezustände, role=log für fortlaufende Aktualisierungen und role=alert für Fehler. Ein Chat-Transkript ist der Lehrbuchfall für role=log: MDN beschreibt es als Live-Region, in die neue Inhalte sinnvoll geordnet am Ende hineinkommen, und so wächst auch ein Gespräch.

Drei Details entscheiden, wie sich das in der Praxis anfühlt. Eine role=log-Region hat einen impliziten aria-live-Wert von polite, Ansagen warten also, bis der Nutzer inaktiv ist, und einen impliziten aria-atomic von false, gesprochen wird also nur die Differenz. Die Region braucht einen zugänglichen Namen, also vergeben Sie aria-label oder aria-labelledby. Und das Transkript muss die fertige Nachricht enthalten, denn ein Screenreader-Nutzer, der das Log später findet, erwartet die Antwort dort als lesbaren Text.

Ansage-PipelineRoh-Token werden zu satzgroßen Stücken gepuffert, einmal in eine polite Log-Region veröffentlicht und vom Screenreader gesprochen, wenn der Nutzer inaktiv ist.TokensRoh-StreamPuffersatzgroßLog-RegionpoliteDeltaeinmal angesagtReaderSprache bei Ruhe
Der Puffer ist das Barrierefreiheits-Feature: Die Lesende hört eine fertige Antwort, nicht fünfzig Token-Updates.

Halten Sie die Ansagefläche klein: eine Statuszeile für flüchtige Zustände wie denken, gestoppt und Fehler, und die Log-Region für Inhalte. Weil polite Live-Regionen nur Geändertes ansagen, erzeugt das einmalige Veröffentlichen der Assistentennachricht nach Streamende eine saubere Einzelansage, während jede Token-Strecke ein Stottern ergäbe. Das WAI-Understanding-Dokument zu 4.1.3 warnt auch in die andere Richtung: Eine Anwendung kann zu gesprächig werden, also testen Sie den Ansagerhythmus mit echten Screenreader-Nutzerinnen und -Nutzern statt zu raten.

<section aria-labelledby="chat-heading">
  <h2 id="chat-heading">Support chat</h2>

  <!-- transcript: finished messages announced politely, as a delta -->
  <ol role="log" aria-live="polite" aria-label="Chat messages">
    <li v-for="m in messages" :key="m.id">
      <span class="who">{{ m.role === 'user' ? 'You' : 'Assistant' }}</span>
      <div v-html="m.html"></div>
    </li>
  </ol>

  <!-- transient state: announced once, never focused -->
  <p role="status">{{ statusText }}</p>

  <form @submit.prevent="send">
    <label for="chat-input">Message</label>
    <textarea id="chat-input" v-model="draft"
      @keydown.enter.exact.prevent="send"></textarea>
    <button type="button" v-if="status === 'streaming'" @click="stop">Stop</button>
    <button type="submit" :disabled="status === 'streaming'">Send</button>
  </form>
</section>

Tastatur und Fokus: der Composer ist die Anwendung

Alles in einem Chat muss allein mit der Tastatur funktionieren, und beim Composer bricht das meistens. Lassen Sie den Fokus nach dem Senden in der TextArea, damit eine Nutzerin, die Frage um Frage feuert, nie zurückspringen muss. Enter sendet und Shift+Enter fügt einen Zeilenumbruch ein, aber greifen Sie die Taste nur auf der TextArea selbst ab, nie auf einem umschließenden div. Send und Stop sind echte Buttons, Enter und Space funktionieren also gratis, und der Stop-Button, der mitten im Stream erscheint, darf niemandem den Fokus wegnehmen, der noch tippt. Nichts darf nur über Hover erreichbar sein.

Zwei Kriterien in WCAG 2.2 betreffen dieses Layout direkt. Target Size (Minimum), 2.5.8 auf Stufe AA, verlangt Zeigerziele von mindestens 24 mal 24 CSS-Pixeln oder ausreichend Abstand um kleinere, was die Versuchung nimmt, Stop als winziges Inline-Glyphe zu zeichnen. Focus Not Obscured (Minimum), 2.4.11 auf Stufe AA, verlangt, dass eine Komponente, die Tastaturfokus erhält, nicht vollständig von autorenschaftlichem Inhalt verdeckt wird, und genau das ist die Gefahr einer klebenden Composer-Leiste über einem scrollenden Transkript: Fügen Sie scroll padding und scroll margin hinzu, damit Links in Nachrichten beim Fokus sichtbar bleiben.

prefers-reduced-motion respektieren

prefers-reduced-motion erkennt die Einstellung, die Menschen einschalten, um unnötige Bewegung zu minimieren, und MDN nennt Skalieren und Verschieben großer Objekte als klassische Auslöser von Unwohlsein bei Menschen mit vestibulären Bewegungsstörungen. Ein Chat ist voll davon: pulsierende Tipp-Punkte, eine blinkende Einfügemarke, ein weiches Auto-Scroll, das das Transkript bei jedem Chunk hinaufschiebt. Die Lösung ist billig und CSS: behalten Sie die Animation für alle, die sie wollen, und ersetzen Sie sie für alle anderen durch einen sofortigen Zustandswechsel.

.typing-dot { animation: pulse 1.1s ease-in-out infinite; }
.chat-log { scroll-behavior: smooth; }

@media (prefers-reduced-motion: reduce) {
  .typing-dot { animation: none; }
  .chat-log { scroll-behavior: auto; }
}

Zwei Meinungen, die sich in einem Review zu verteidigen lohnen. Erstens: Bei reduzierter Bewegung sollte Auto-Scroll springen statt gleiten, und es sollte ganz aufhören, sobald der Nutzer nach oben scrollt, um etwas zu lesen, denn eine lesende Person zu bekämpfen ist schlechter als jede Animation. Zweitens: Verstecken Sie nie das einzige Signal für Aktivität hinter Bewegung; wenn der Tipp-Indikator der einzige Beweis ist, dass das Modell arbeitet, hat ein Nutzer mit reduzierter Bewegung gar keinen Beweis. Falls Sie die Präferenz serverseitig brauchen, gibt es dafür den User-Agent-Client-Hint Sec-CH-Prefers-Reduced-Motion.

Die Chat-UI wie ein Mensch testen, nicht wie ein Linter

Testen Sie so, wie der Fehler bei echten Nutzern ankommt. Machen Sie einen Durchgang nur mit der Tastatur: Tab zum Composer, Enter zum Senden, prüfen, dass Stop erscheint und während des Streamings erreichbar ist und Retry den Fokus übernimmt, wenn er auftaucht. Machen Sie einen Screenreader-Durchgang mit VoiceOver auf macOS und NVDA unter Windows, den beiden, die für meine österreichischen und ungarischen Kunden zählen: die Antwort sollte einmal angesagt werden, der Fehler überhaupt, und nichts sollte sich wiederholen, während Tokens streamen. Lassen Sie in der CI einen automatischen axe-Scan laufen, um fehlende Namen und Rollen zu finden, aber behandeln Sie ihn als Boden, nicht als Beweis.

Dann fügen Sie die drei Regressionstests hinzu, die Sie um zwei Uhr nachts wollen. Erstens: die Verbindung mitten im Stream abreißen lassen und prüfen, dass die UI im Fehlerzustand landet, mit Retry-Aktion und ohne unbehobene Ablehnung. Zweitens: Stop drücken und prüfen, dass der Zustand nicht error ist und die Teilantwort überlebt. Drittens: eine Nachricht senden, die das Modell ein script-Tag ausgeben lässt, und prüfen, dass es als Text gerendert wird. Diese drei Tests decken den größten Teil dieses Artikels ab und laufen in jedem Headless-Browser. Wenn Sie in die EU verkaufen, behandelt der European Accessibility Act zugängliche Oberflächen längst als geschäftliche Anforderung, diese Arbeit ist also ebenfalls kein Optional.

Quellen

  1. MDN: Using server-sent events
  2. MDN: AbortController
  3. MDN: ReadableStream
  4. MDN: TextDecoderStream
  5. MDN: ARIA log role
  6. W3C: Understanding SC 4.1.3 Status Messages
  7. W3C WAI: What's new in WCAG 2.2
  8. MDN: prefers-reduced-motion
  9. DOMPurify README
  10. markdown-it README
  11. AI SDK docs: useChat
  12. Vue docs: nextTick

Häufige Fragen

Soll der streamende Text selbst in einer aria-live-Region liegen?

Nein. Die fertige Nachricht kommt in die Log-Region, eine kleine Statuszeile trägt denken, gestoppt und Fehler. Eine Live-Region, die Token-Updates erhält, verwandelt den Screenreader in ein Stottergerät; veröffentlichen Sie beim Abschluss oder an Satzgrenzen und lassen Sie das Transkript den vollen Text zum Erkunden bereithalten.

Warum nicht einfach EventSource für die Antwort verwenden?

Weil sie einseitig ist und die Wiederverbindung automatisch erfolgt. EventSource empfängt nur, das POST mit Ihrer Nachrichtenliste braucht also ohnehin Fetch, und der eingebaute Retry kann einen bereits halb gerenderten Stream still wiederholen. Ein Fetch-Response-Body vereint Anfrage und Antwort in einem Aufruf und lässt den Abbruch in Ihrer Hand.

Reicht markdown-it nicht allein?

Es entkommt rohes HTML standardmäßig, aber ein Sanitizer ist günstige Versicherung. Autolinkisierte URLs, Plugin-Ausgaben und künftige Optionen können die Fläche verbreitern, und DOMPurify wehrt Parser-Mutations-, Namensraum- und Clobbering-Tricks ab, die handgeschriebene Filter übersehen. Führen Sie ihn als Letztes aus und ändern Sie den String danach nie wieder.

Wie bleibt die UI während des Streamings responsiv?

Chargenweise updaten. Das AI SDK bietet für Vue eine throttle-Option, und Vue puffert DOM-Patches ohnehin bis zum nächsten Tick, solange Sie im selben Tick keine schweren Kindkomponenten und keine Scroll-Mathematik auslösen. Weniger Renders pro Sekunde schützen auch das Interaction Next Paint der Seite.

Muss ich wirklich mit einem Screenreader testen?

Ja, und billiger als Sie fürchten. Fünf Minuten mit VoiceOver auf macOS oder NVDA unter Windows legen die zwei klassischen Fehler offen: ein Transkript, das nie angesagt wird, und ein Fehlerzustand, der visuell offensichtlich und akustisch still ist. Automatische Prüfungen finden fehlende Rollen; sie sagen Ihnen nicht, ob die Ansagen nutzbar sind.

Klingt nach dem, was du suchst?

Erzähl mir von deinem Projekt oder deiner Stelle – ich freue mich, von dir zu hören.