[{"data":1,"prerenderedAt":773},["ShallowReactive",2],{"blog-vue-ai-chat-ui-streaming-accessibility-de":3},{"slug":4,"published":5,"minutes":6,"category":7,"tags":8,"keywords":14,"about":26,"sources":36,"cover":73,"og":74,"expertise":75,"locales":76,"lang":78,"title":80,"description":81,"coverAlt":82,"metaTitle":83,"takeaways":84,"faq":90,"toc":106,"blocks":137,"others":527},"vue-ai-chat-ui-streaming-accessibility","2026-10-17",11,"web",[9,10,11,12,13],"Vue","Nuxt","Accessibility","Streaming","AI chat",[15,16,17,18,19,20,21,22,23,24,25],"vue ai chat ui","nuxt llm streaming","sse vs fetch streaming","aria-live streaming text","accessible chat interface","abort fetch stream vue","dompurify markdown llm","role log chat","reduced motion chat ui","wcag 2.2 chat","screen reader streaming",[27,30,33],{"name":28,"url":29},"Web accessibility","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FWeb_accessibility",{"name":31,"url":32},"Server-sent events","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FServer-sent_events",{"name":34,"url":35},"Vue.js","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FVue.js",[37,40,43,46,49,52,55,58,61,64,67,70],{"title":38,"url":39},"MDN: Using server-sent events","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FServer-sent_events\u002FUsing_server-sent_events",{"title":41,"url":42},"MDN: AbortController","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAbortController",{"title":44,"url":45},"MDN: ReadableStream","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FReadableStream",{"title":47,"url":48},"MDN: TextDecoderStream","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FTextDecoderStream",{"title":50,"url":51},"MDN: ARIA log role","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAccessibility\u002FARIA\u002FReference\u002FRoles\u002Flog_role",{"title":53,"url":54},"W3C: Understanding SC 4.1.3 Status Messages","https:\u002F\u002Fwww.w3.org\u002FWAI\u002FWCAG22\u002FUnderstanding\u002Fstatus-messages.html",{"title":56,"url":57},"W3C WAI: What's new in WCAG 2.2","https:\u002F\u002Fwww.w3.org\u002FWAI\u002Fstandards-guidelines\u002Fwcag\u002Fnew-in-22\u002F",{"title":59,"url":60},"MDN: prefers-reduced-motion","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FCSS\u002F@media\u002Fprefers-reduced-motion",{"title":62,"url":63},"DOMPurify README","https:\u002F\u002Fgithub.com\u002Fcure53\u002FDOMPurify",{"title":65,"url":66},"markdown-it README","https:\u002F\u002Fgithub.com\u002Fmarkdown-it\u002Fmarkdown-it",{"title":68,"url":69},"AI SDK docs: useChat","https:\u002F\u002Fai-sdk.dev\u002Fdocs\u002Fai-sdk-ui\u002Fchatbot",{"title":71,"url":72},"Vue docs: nextTick","https:\u002F\u002Fvuejs.org\u002Fapi\u002Fgeneral.html#nexttick","\u002Fimages\u002Fblog\u002Fvue-ai-chat-ui-streaming-accessibility\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fvue-ai-chat-ui-streaming-accessibility\u002Fog.jpg","vue-nuxt-developer",[77,78,79],"en","de","hu","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.","Eine Nachrichtenkarte mit blinkendem Cursor, eine gepunktete Linie öffnet sich zu Wellen, daneben ein Haken.","Streaming-Chat mit KI in Vue, barrierefrei · Balázs Csorba",[85,86,87,88,89],"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.",[91,94,97,100,103],{"q":92,"a":93},"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.",{"q":95,"a":96},"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.",{"q":98,"a":99},"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.",{"q":101,"a":102},"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.",{"q":104,"a":105},"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.",[107,110,113,116,119,122,125,128,131,134],{"id":108,"title":109},"streaming-choices","Das Transportformat wählen, bevor Sie die Komponente wählen",{"id":111,"title":112},"reading-the-stream","Einen Fetch-Stream in einem Vue-Composable lesen",{"id":114,"title":115},"abort-and-retry","Abbrechen ist ein Feature, kein Fehler",{"id":117,"title":118},"stream-state-model","Die Verbindung als vier Zustände modellieren",{"id":120,"title":121},"rendering-markdown-safely","Markdown rendern, ohne das DOM zu übergeben",{"id":123,"title":124},"announcing-streams","Die Antwort ansagen, nicht jeden Token",{"id":126,"title":127},"keyboard-and-focus","Tastatur und Fokus: der Composer ist die Anwendung",{"id":129,"title":130},"reduced-motion","prefers-reduced-motion respektieren",{"id":132,"title":133},"testing-the-chat-ui","Die Chat-UI wie ein Mensch testen, nicht wie ein Linter",{"id":135,"title":136},"sources","Quellen",[138,142,160,163,189,192,239,254,255,277,279,289,290,299,301,316,325,326,348,354,366,367,370,372,388,395,396,411,430,437,440,442,451,452,455,458,459,464,466,473,474,477,485,486],{"type":139,"content":140},"paragraph",[141],"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.",{"type":139,"content":143},[144,145,149,150,153,154,159],"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 ",{"tag":146,"children":147},"code",[148],"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 ",{"tag":146,"children":151},[152],"prefers-reduced-motion"," respektieren. Die Grundlagen des Streamings aus einer Nuxt-Server-Route stehen in meinem früheren Artikel über ",{"tag":155,"to":156,"children":157},"link","\u002Fblog\u002Fnuxt-llm-features-ai-sdk-streaming",[158],"LLM-Features in Nuxt","; hier geht es tiefer in die Oberfläche um den Stream herum.",{"type":161,"level":162,"id":108,"text":109},"heading",2,{"type":139,"content":164},[165,166,169,170,173,174,177,178,177,181,184,185,188],"Zwei Transporte beherrschen Chat-Oberflächen. Das browser-native ",{"tag":146,"children":167},[168],"EventSource"," spricht Server-Sent Events: der Server antwortet mit dem Content-Type ",{"tag":146,"children":171},[172],"text\u002Fevent-stream"," und UTF-8-Nachrichten aus den Feldern ",{"tag":146,"children":175},[176],"event",", ",{"tag":146,"children":179},[180],"data",{"tag":146,"children":182},[183],"id"," und ",{"tag":146,"children":186},[187],"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.",{"type":139,"content":190},[191],"Es gibt auch eine Infrastruktur-Grenze, die man vorher kennen sollte. Über HTTP\u002F1.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\u002F2 die Zahl simultaner Streams zwischen Browser und Server ausgehandelt wird und standardmäßig bei 100 liegt. Wer noch über HTTP\u002F1.1 terminiert, sitzt mit wenigen offenen Chat-Tabs bereits an der Decke.",{"type":193,"head":194,"rows":205},"table",[195,197,199,201,203],[196],"Ansatz",[198],"Richtung",[200],"Auth",[202],"Wiederverbindung",[204],"Was Sie selbst behandeln",[206,217,228],[207,209,211,213,215],[208],"EventSource (SSE)",[210],"nur Server zu Client",[212],"GET, keine Request-Body",[214],"automatisch über das retry-Feld",[216],"den gesamten Sendepfad",[218,220,222,224,226],[219],"fetch plus ReadableStream",[221],"Request-Body und Response-Stream",[223],"volle Header und Body",[225],"keine, Sie entscheiden",[227],"Parsing, Abbruch, Fehlerzustände",[229,231,233,235,237],[230],"AI SDK useChat",[232],"POST mit UI-Message-Stream",[234],"konfigurierbare Header und Body",[236],"vom Transport gehandhabt",[238],"Zustand, Rendering, Barrierefreiheit",{"type":139,"content":240},[241,242,245,246,249,250,253],"Für einen Chat, der eine Nachrichtenliste sendet, einen ",{"tag":146,"children":243},[244],"Authorization","-Header braucht und beim Klick auf Stop sofort stoppen muss, ist der Fetch-Response-Body als ",{"tag":146,"children":247},[248],"ReadableStream"," die ehrliche Wahl, und genau das tut der ",{"tag":146,"children":251},[252],"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.",{"type":161,"level":162,"id":111,"text":112},{"type":139,"content":256},[257,258,260,261,264,265,268,269,272,273,276],"Ein Fetch-Response legt seinen Body als ",{"tag":146,"children":259},[248]," offen. Sie sperren ihn mit ",{"tag":146,"children":262},[263],"getReader()"," und lesen in einer Schleife, oder Sie überspringen den Reader und iterieren die Chunks direkt: die Streams-API implementiert das async-iterable-Protokoll, ",{"tag":146,"children":266},[267],"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 ",{"tag":146,"children":270},[271],"preventCancel"," übergeben. Für die Bytes leiten Sie den Body durch einen ",{"tag":146,"children":274},[275],"TextDecoderStream",", die Stream-Form von TextDecoder, die eine Binärkodierung in einen String-Stream umwandelt.",{"type":146,"code":278},"\u003Cscript setup lang=\"ts\">\nimport { ref, onBeforeUnmount } from \"vue\"\n\ntype Status = \"ready\" | \"streaming\" | \"error\"\nconst reply = ref(\"\")\nconst status = ref\u003CStatus>(\"ready\")\nlet controller: AbortController | null = null\n\nasync function send(text: string) {\n  controller?.abort()\n  controller = new AbortController()\n  status.value = \"streaming\"\n  reply.value = \"\"\n  try {\n    const res = await fetch(\"\u002Fapi\u002Fchat\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application\u002Fjson\" },\n      body: JSON.stringify({ message: text }),\n      signal: controller.signal,\n    })\n    if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)\n    const reader = res.body\n      .pipeThrough(new TextDecoderStream())\n      .getReader()\n    let leftover = \"\"\n    for (;;) {\n      const { done, value } = await reader.read()\n      if (done) break\n      leftover += value\n      const events = leftover.split(\"\\n\\n\")\n      leftover = events.pop() ?? \"\"\n      for (const ev of events) {\n        if (ev.startsWith(\"data: \")) reply.value += ev.slice(6)\n      }\n    }\n    status.value = \"ready\"\n  } catch (err) {\n    \u002F\u002F a Stop press is not a failure\n    status.value = (err as Error).name === \"AbortError\" ? \"ready\" : \"error\"\n  }\n}\n\nonBeforeUnmount(() => controller?.abort())\n\u003C\u002Fscript>",{"type":139,"content":280},[281,282,284,285,288],"Zwei Zeilen in dieser Schleife verdienen einen Kommentar. Der leftover-Puffer existiert, weil Netzwerk-Chunks nicht an Ihrem Protokoll ausgerichtet sind: ein einzelnes ",{"tag":146,"children":283},[180],"-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 ",{"tag":146,"children":286},[287],"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.",{"type":161,"level":162,"id":114,"text":115},{"type":139,"content":291},[292,294,295,298],{"tag":146,"children":293},[148]," ist das Standardwerkzeug dafür: seine ",{"tag":146,"children":296},[297],"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.",{"type":146,"code":300},"\u002F\u002F stop is user intent: abort and keep whatever arrived\nfunction stop() {\n  controller?.abort()\n  status.value = \"ready\"\n}\n\n\u002F\u002F retry resends the last user message, not the last wire payload\nasync function retry() {\n  const last = messages.value.at(-1)\n  if (last?.role === \"user\") await send(last.text)\n}",{"type":139,"content":302},[303,304,307,308,311,312,315],"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: ",{"tag":146,"children":305},[306],"stop"," bricht die Fetch-Anfrage ab, ",{"tag":146,"children":309},[310],"regenerate"," lässt das Modell die letzte Nachricht neu verarbeiten, und ",{"tag":146,"children":313},[314],"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.",{"type":317,"attrs":318,"inner":322,"caption":323},"diagram",{"viewBox":319,"role":320,"aria-labelledby":321},"0 0 720 200","img","vaic1-t vaic1-d","\u003Ctitle id=\"vaic1-t\">Lebenszyklus eines Chat-Streams\u003C\u002Ftitle>\u003Cdesc id=\"vaic1-d\">Der 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.\u003C\u002Fdesc>\u003Cdefs>\u003Cmarker id=\"vaic1-ah\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\u003Cpath d=\"M0 0L10 5L0 10z\" class=\"d-head\" \u002F>\u003C\u002Fmarker>\u003C\u002Fdefs>\u003Crect x=\"20\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"79\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">Composer\u003C\u002Ftext>\u003Ctext x=\"79\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">senden\u003C\u002Ftext>\u003Cpath d=\"M138 68H158\" class=\"d-line\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"160\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-sky\" \u002F>\u003Ctext x=\"219\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">POST\u003C\u002Ftext>\u003Ctext x=\"219\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">\u002Fapi\u002Fchat\u003C\u002Ftext>\u003Cpath d=\"M278 68H298\" class=\"d-line\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"300\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-gold\" \u002F>\u003Ctext x=\"359\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">Stream\u003C\u002Ftext>\u003Ctext x=\"359\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">Token-Chunks\u003C\u002Ftext>\u003Cpath d=\"M418 68H438\" class=\"d-line\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"440\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-mint\" \u002F>\u003Ctext x=\"499\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">Rendern\u003C\u002Ftext>\u003Ctext x=\"499\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">sanitieren\u003C\u002Ftext>\u003Cpath d=\"M558 68H578\" class=\"d-line\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"580\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"639\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">Fertig\u003C\u002Ftext>\u003Ctext x=\"639\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">ready-Zustand\u003C\u002Ftext>\u003Cpath d=\"M359 106V140\" class=\"d-dash\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"300\" y=\"142\" width=\"118\" height=\"52\" rx=\"10\" class=\"d-fill-muted\" \u002F>\u003Ctext x=\"359\" y=\"166\" text-anchor=\"middle\" class=\"d-text\">Stop\u003C\u002Ftext>\u003Ctext x=\"359\" y=\"184\" text-anchor=\"middle\" class=\"d-small\">abort()\u003C\u002Ftext>\u003Cpath d=\"M499 106V140\" class=\"d-dash\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"440\" y=\"142\" width=\"118\" height=\"52\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"499\" y=\"166\" text-anchor=\"middle\" class=\"d-text\">Fehler\u003C\u002Ftext>\u003Ctext x=\"499\" y=\"184\" text-anchor=\"middle\" class=\"d-small\">retry\u003C\u002Ftext>\u003C\u002Fsvg>",[324],"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.",{"type":161,"level":162,"id":117,"text":118},{"type":139,"content":327},[328,329,331,332,335,336,339,340,343,344,347],"Leiten Sie jede sichtbare Steuerung aus einem einzigen Statuswert ab. Der ",{"tag":146,"children":330},[252],"-Hook des AI SDK liefert einen Status mit vier Werten: ",{"tag":146,"children":333},[334],"submitted"," bedeutet, die Anfrage ist unterwegs und der Stream noch nicht gestartet, ",{"tag":146,"children":337},[338],"streaming"," bedeutet, Chunks treffen ein, ",{"tag":146,"children":341},[342],"ready"," bedeutet, die Antwort ist fertig und eine neue Nachricht kann gesendet werden, und ",{"tag":146,"children":345},[346],"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.",{"type":139,"content":349},[350,351,353],"Throttling gehört ins Zustandsmodell, nicht in ein Performance-Backlog. ",{"tag":146,"children":352},[252]," 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.",{"type":139,"content":355},[356,357,360,361,365],"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 ",{"tag":146,"children":358},[359],"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 ",{"tag":155,"to":362,"children":363},"\u002Fblog\u002Fnuxt-core-web-vitals-performance",[364],"Core-Web-Vitals-Orientierung für Nuxt-Seiten"," geht.",{"type":161,"level":162,"id":120,"text":121},{"type":139,"content":368},[369],"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.",{"type":146,"code":371},"import MarkdownIt from \"markdown-it\"\nimport DOMPurify from \"dompurify\"\n\n\u002F\u002F html bleibt false: rohes HTML in der Modell-Ausgabe wird maskiert\nconst md = new MarkdownIt({ linkify: true })\n\nexport function renderAssistantMarkdown(src: string): string {\n  const html = md.render(src)\n  return DOMPurify.sanitize(html, {\n    USE_PROFILES: { html: true }, \u002F\u002F Antworten brauchen weder SVG noch MathML\n    ADD_ATTR: [\"target\"],\n  })\n}",{"type":373,"variant":374,"title":375,"body":376},"callout","warn","Zuletzt sanitieren, und zwar ernst gemeint",[377],[378,379,382,383,387],"Die Dokumentation von DOMPurify selbst warnt: Wer zuerst sanitisiert und danach die Markups verändert, hebt die Wirkung des Sanitizers leicht wieder auf. Also darf nach ",{"tag":146,"children":380},[381],"sanitize()"," kein Syntax-Highlighter, Link-Umschreiber oder Copy-Button-Helfer mehr den String anfassen; erledigen Sie das als Vue-Komponenten oder Data-Attribute über der sanitisierten Ausgabe. Zwei weitere Stolperfallen aus derselben README: die Standardkonfiguration erlaubt auch SVG und MathML, was eine Chat-Antwort nie braucht, und wenn Sie serverseitig sanitieren, verwenden Sie nicht happy-dom, das laut Projekt nicht als sicher gilt. Die breitere Moderationsgeschichte steht in meinem Text über ",{"tag":155,"to":384,"children":385},"\u002Fblog\u002Fllm-output-guardrails-moderation",[386],"LLM-Output-Guardrails","; das Rendering ist die letzte Linie dieser Verteidigung, nicht die erste.",{"type":139,"content":389},[390,391,394],"Halten Sie die Allow-Liste schmal und die Pipeline einrichtig: Markdown rein, sanitisiertes HTML raus, und erst dann mit ",{"tag":146,"children":392},[393],"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.",{"type":161,"level":162,"id":123,"text":124},{"type":139,"content":397},[398,399,402,403,406,407,410],"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 ",{"tag":146,"children":400},[401],"role=status"," für Ergebnisse und Wartezustände, ",{"tag":146,"children":404},[405],"role=log"," für fortlaufende Aktualisierungen und ",{"tag":146,"children":408},[409],"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.",{"type":139,"content":412},[413,414,417,418,421,422,425,426,429],"Drei Details entscheiden, wie sich das in der Praxis anfühlt. Eine role=log-Region hat einen impliziten ",{"tag":146,"children":415},[416],"aria-live","-Wert von polite, Ansagen warten also, bis der Nutzer inaktiv ist, und einen impliziten ",{"tag":146,"children":419},[420],"aria-atomic"," von false, gesprochen wird also nur die Differenz. Die Region braucht einen zugänglichen Namen, also vergeben Sie ",{"tag":146,"children":423},[424],"aria-label"," oder ",{"tag":146,"children":427},[428],"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.",{"type":317,"attrs":431,"inner":434,"caption":435},{"viewBox":432,"role":320,"aria-labelledby":433},"0 0 720 170","vaic2-t vaic2-d","\u003Ctitle id=\"vaic2-t\">Ansage-Pipeline\u003C\u002Ftitle>\u003Cdesc id=\"vaic2-d\">Roh-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.\u003C\u002Fdesc>\u003Cdefs>\u003Cmarker id=\"vaic2-ah\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\u003Cpath d=\"M0 0L10 5L0 10z\" class=\"d-head\" \u002F>\u003C\u002Fmarker>\u003C\u002Fdefs>\u003Crect x=\"20\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"79\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Tokens\u003C\u002Ftext>\u003Ctext x=\"79\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">Roh-Stream\u003C\u002Ftext>\u003Cpath d=\"M138 85H158\" class=\"d-line\" marker-end=\"url(#vaic2-ah)\" \u002F>\u003Crect x=\"160\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-sky\" \u002F>\u003Ctext x=\"219\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Puffer\u003C\u002Ftext>\u003Ctext x=\"219\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">satzgroß\u003C\u002Ftext>\u003Cpath d=\"M278 85H298\" class=\"d-line\" marker-end=\"url(#vaic2-ah)\" \u002F>\u003Crect x=\"300\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-gold\" \u002F>\u003Ctext x=\"359\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Log-Region\u003C\u002Ftext>\u003Ctext x=\"359\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">polite\u003C\u002Ftext>\u003Cpath d=\"M418 85H438\" class=\"d-line\" marker-end=\"url(#vaic2-ah)\" \u002F>\u003Crect x=\"440\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-mint\" \u002F>\u003Ctext x=\"499\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Delta\u003C\u002Ftext>\u003Ctext x=\"499\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">einmal angesagt\u003C\u002Ftext>\u003Cpath d=\"M558 85H578\" class=\"d-line\" marker-end=\"url(#vaic2-ah)\" \u002F>\u003Crect x=\"580\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"639\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Reader\u003C\u002Ftext>\u003Ctext x=\"639\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">Sprache bei Ruhe\u003C\u002Ftext>\u003C\u002Fsvg>",[436],"Der Puffer ist das Barrierefreiheits-Feature: Die Lesende hört eine fertige Antwort, nicht fünfzig Token-Updates.",{"type":139,"content":438},[439],"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.",{"type":146,"code":441},"\u003Csection aria-labelledby=\"chat-heading\">\n  \u003Ch2 id=\"chat-heading\">Support chat\u003C\u002Fh2>\n\n  \u003C!-- transcript: finished messages announced politely, as a delta -->\n  \u003Col role=\"log\" aria-live=\"polite\" aria-label=\"Chat messages\">\n    \u003Cli v-for=\"m in messages\" :key=\"m.id\">\n      \u003Cspan class=\"who\">{{ m.role === 'user' ? 'You' : 'Assistant' }}\u003C\u002Fspan>\n      \u003Cdiv v-html=\"m.html\">\u003C\u002Fdiv>\n    \u003C\u002Fli>\n  \u003C\u002Fol>\n\n  \u003C!-- transient state: announced once, never focused -->\n  \u003Cp role=\"status\">{{ statusText }}\u003C\u002Fp>\n\n  \u003Cform @submit.prevent=\"send\">\n    \u003Clabel for=\"chat-input\">Message\u003C\u002Flabel>\n    \u003Ctextarea id=\"chat-input\" v-model=\"draft\"\n      @keydown.enter.exact.prevent=\"send\">\u003C\u002Ftextarea>\n    \u003Cbutton type=\"button\" v-if=\"status === 'streaming'\" @click=\"stop\">Stop\u003C\u002Fbutton>\n    \u003Cbutton type=\"submit\" :disabled=\"status === 'streaming'\">Send\u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Fsection>",{"type":373,"variant":443,"title":444,"body":445},"note","Abschlüsse ansagen, nicht Tokens",[446],[447,448,450],"Eine streamende Antwort ändert sich alle paar zehn Millisekunden. Jede Änderung in einer Live-Region zu lassen bedeutet für einen Screenreader-Nutzer ein ständiges Stottern aus Halbsätzen, ohne jemals ein Bild der Antwort zu bekommen. Puffern Sie die Chunks und veröffentlichen Sie die Nachricht in der Log-Region, wenn der Stream endet oder an Satzgrenzen; MDN verweist auch darauf, dass ",{"tag":146,"children":449},[420]," auf true die gesamte Region noch einmal vorlesen lässt, nützlich für kurzen Statustext und lärmig für eine lange Antwort. Die falsche Version dieses Features ist schlechter als gar keine, also liefern Sie die gepufferte aus.",{"type":161,"level":162,"id":126,"text":127},{"type":139,"content":453},[454],"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.",{"type":139,"content":456},[457],"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.",{"type":161,"level":162,"id":129,"text":130},{"type":139,"content":460},[461,463],{"tag":146,"children":462},[152]," 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.",{"type":146,"code":465},".typing-dot { animation: pulse 1.1s ease-in-out infinite; }\n.chat-log { scroll-behavior: smooth; }\n\n@media (prefers-reduced-motion: reduce) {\n  .typing-dot { animation: none; }\n  .chat-log { scroll-behavior: auto; }\n}",{"type":139,"content":467},[468,469,472],"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 ",{"tag":146,"children":470},[471],"Sec-CH-Prefers-Reduced-Motion",".",{"type":161,"level":162,"id":132,"text":133},{"type":139,"content":475},[476],"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.",{"type":139,"content":478},[479,480,484],"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 ",{"tag":155,"to":481,"children":482},"\u002Fblog\u002Feuropean-accessibility-act-b2b-shop",[483],"European Accessibility Act"," zugängliche Oberflächen längst als geschäftliche Anforderung, diese Arbeit ist also ebenfalls kein Optional.",{"type":161,"level":162,"id":135,"text":136},{"type":487,"ordered":488,"items":489},"list",true,[490,494,497,500,503,506,509,512,515,518,521,524],[491],{"tag":492,"href":39,"children":493},"a",[38],[495],{"tag":492,"href":42,"children":496},[41],[498],{"tag":492,"href":45,"children":499},[44],[501],{"tag":492,"href":48,"children":502},[47],[504],{"tag":492,"href":51,"children":505},[50],[507],{"tag":492,"href":54,"children":508},[53],[510],{"tag":492,"href":57,"children":511},[56],[513],{"tag":492,"href":60,"children":514},[59],[516],{"tag":492,"href":63,"children":517},[62],[519],{"tag":492,"href":66,"children":520},[65],[522],{"tag":492,"href":69,"children":523},[68],[525],{"tag":492,"href":72,"children":526},[71],[528,601,657,725],{"slug":529,"published":5,"minutes":530,"category":7,"tags":531,"keywords":536,"about":548,"sources":557,"cover":594,"og":595,"expertise":596,"locales":597,"lang":78,"title":598,"description":599,"coverAlt":600},"schema-org-structured-data-b2b-shop",10,[532,533,534,535,10],"Schema.org","Structured data","B2B e-commerce","Technical SEO",[537,538,539,540,541,542,543,544,545,546,547],"schema.org b2b shop","product structured data b2b","merchant listing structured data","product snippet vs merchant listing","unitpricespecification referencequantity","productgroup variants structured data","organization schema vatid","b2b prices behind login seo","json-ld nuxt schema org","merchant return policy structured data","mpn gtin structured data",[549,551,554],{"name":532,"url":550},"https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FSchema.org",{"name":552,"url":553},"JSON-LD","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FJSON-LD",{"name":555,"url":556},"Business-to-business","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FBusiness-to-business",[558,561,564,567,570,573,576,579,582,585,588,591],{"title":559,"url":560},"Google Search Central: Introduction to Product structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fproduct",{"title":562,"url":563},"Google Search Central: Merchant listing (Product, Offer) structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fmerchant-listing",{"title":565,"url":566},"Google Search Central: Product snippet structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fproduct-snippet",{"title":568,"url":569},"Google Search Central: Product variant structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fproduct-variants",{"title":571,"url":572},"Google Search Central: Organization structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Forganization",{"title":574,"url":575},"Google Search Central: Merchant return policy structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Freturn-policy",{"title":577,"url":578},"Google Search Central: General structured data guidelines","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fsd-policies",{"title":580,"url":581},"Google Search Central: Generate structured data with JavaScript","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fgenerate-structured-data-with-javascript",{"title":583,"url":584},"Schema.org: Offer","https:\u002F\u002Fschema.org\u002FOffer",{"title":586,"url":587},"Schema.org: UnitPriceSpecification","https:\u002F\u002Fschema.org\u002FUnitPriceSpecification",{"title":589,"url":590},"Unhead: Product schema (defineProduct)","https:\u002F\u002Funhead.unjs.io\u002Fdocs\u002Fschema-org\u002Fapi\u002Fschema\u002Fproduct",{"title":592,"url":593},"Nuxt SEO: Schema.org identity setup","https:\u002F\u002Fnuxtseo.com\u002Fdocs\u002Fschema-org\u002Fguides\u002Fsetup-identity","\u002Fimages\u002Fblog\u002Fschema-org-structured-data-b2b-shop\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fschema-org-structured-data-b2b-shop\u002Fog.jpg","b2b-ecommerce-developer",[77,78,79],"Schema.org-Strukturdaten für B2B-Shops: Preise hinter Logins, Einheiten und Varianten","Produkt-Markup für B2B-Shops: Snippet oder Händlereintrag, welcher Preis, Artikelnummern, Grundpreise, Varianten, Organization und SSR in Nuxt.","Ein Produktrechteck mit dünnem Rahmen und einem kleinen Feld, das eine niedrige Wand verdeckt.",{"slug":602,"published":5,"minutes":603,"category":7,"tags":604,"keywords":609,"about":621,"sources":629,"cover":651,"og":652,"expertise":596,"locales":653,"lang":78,"title":654,"description":655,"coverAlt":656},"ai-assistant-b2b-shop-customer-support",9,[534,605,606,607,608],"AI assistant","Customer support","LLM agents","EU AI Act",[610,611,612,613,614,615,616,617,618,619,620],"ai assistant b2b shop","ai customer support b2b ecommerce","llm chatbot order status","ai agent customer service tools","chatbot liability air canada","klarna ai assistant results","human handoff chatbot design","eu ai act chatbot disclosure","owasp excessive agency","ai support resolution rate","b2b customer portal chatbot",[622,625,628],{"name":623,"url":624},"Customer service","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FCustomer_service",{"name":626,"url":627},"Chatbot","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FChatbot",{"name":555,"url":556},[630,633,636,639,642,645,648],{"title":631,"url":632},"Klarna: AI assistant handles two-thirds of customer service chats in its first month (press release, 2024)","https:\u002F\u002Fwww.klarna.com\u002Finternational\u002Fpress\u002Fklarna-ai-assistant-handles-two-thirds-of-customer-service-chats-in-its-first-month\u002F",{"title":634,"url":635},"Entrepreneur: Klarna is hiring customer service agents again, according to its CEO (May 2025)","https:\u002F\u002Fwww.entrepreneur.com\u002Fbusiness-news\u002Fklarna-ceo-reverses-course-by-hiring-more-humans-not-ai\u002F491396",{"title":637,"url":638},"ABA Business Law Today: BC tribunal confirms companies remain liable for information provided by AI chatbot","https:\u002F\u002Fwww.americanbar.org\u002Fgroups\u002Fbusiness_law\u002Fresources\u002Fbusiness-law-today\u002F2024-february\u002Fbc-tribunal-confirms-companies-remain-liable-information-provided-ai-chatbot\u002F",{"title":640,"url":641},"OWASP GenAI Security Project: LLM06:2025 Excessive Agency","https:\u002F\u002Fgenai.owasp.org\u002Fllmrisk\u002Fllm062025-excessive-agency\u002F",{"title":643,"url":644},"OWASP GenAI Security Project: LLM05:2025 Improper Output Handling","https:\u002F\u002Fgenai.owasp.org\u002Fllmrisk\u002Fllm052025-improper-output-handling\u002F",{"title":646,"url":647},"EU AI Act, Article 50: Transparency obligations","https:\u002F\u002Fartificialintelligenceact.eu\u002Farticle\u002F50\u002F",{"title":649,"url":650},"Intercom: Fin pricing and billable outcomes","https:\u002F\u002Ffin.ai\u002Fpricing","\u002Fimages\u002Fblog\u002Fai-assistant-b2b-shop-customer-support\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fai-assistant-b2b-shop-customer-support\u002Fog.jpg",[77,78,79],"Ein KI-Assistent für den Support im B2B-Shop: Tools, Berechtigungen und die Übergabe an Menschen","Wie man einen KI-Support-Assistenten für einen B2B-Shop baut: was automatisieren, Tools im Kundenkontext, Übergabe an Menschen, AI Act und Kennzahlen.","Eine hohe Mauer mit schmalem Durchgang: Ein Pfeil führt in ein Feld mit drei Quadraten, ein zweiter nach unten zu einer Ablage.",{"slug":658,"published":5,"minutes":603,"category":7,"tags":659,"keywords":664,"about":676,"sources":686,"cover":719,"og":720,"expertise":75,"locales":721,"lang":78,"title":722,"description":723,"coverAlt":724},"llm-translation-pipeline-i18n",[660,661,662,9,663],"Translation","i18n","LLM","Localization",[665,666,667,668,669,670,671,672,673,674,675],"llm translation pipeline","icu messageformat placeholders","vue i18n llm translation","translation memory llm","glossary enforcement translation","german informal sie du","hungarian translation quality llm","structured output json translation","machine translation post-editing","locale file validation","cldr plural categories",[677,680,683],{"name":678,"url":679},"Internationalization and localization","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FInternationalization_and_localization",{"name":681,"url":682},"ICU MessageFormat","https:\u002F\u002Funicode-org.github.io\u002Ficu\u002Fuserguide\u002Fformat_parse\u002Fmessages\u002F",{"name":684,"url":685},"Vue I18n","https:\u002F\u002Fvue-i18n.intlify.dev\u002F",[687,689,692,695,698,701,704,707,710,713,716],{"title":688,"url":682},"Unicode ICU User Guide: Formatting Messages",{"title":690,"url":691},"Vue I18n: Message Format Syntax","https:\u002F\u002Fvue-i18n.intlify.dev\u002Fguide\u002Fessentials\u002Fsyntax",{"title":693,"url":694},"Nuxt i18n: Getting started","https:\u002F\u002Fi18n.nuxtjs.org\u002Fdocs\u002Fgetting-started",{"title":696,"url":697},"Unicode CLDR: Plural rules","https:\u002F\u002Fcldr.unicode.org\u002Findex\u002Fcldr-spec\u002Fplural-rules",{"title":699,"url":700},"OpenAI: Structured outputs","https:\u002F\u002Fplatform.openai.com\u002Fdocs\u002Fguides\u002Fstructured-outputs",{"title":702,"url":703},"DeepL API: Glossaries","https:\u002F\u002Fdevelopers.deepl.com\u002Fdocs\u002Fapi-reference\u002Fglossaries",{"title":705,"url":706},"W3C: Localization vs. internationalization","https:\u002F\u002Fwww.w3.org\u002FInternational\u002Fquestions\u002Fqa-i18n",{"title":708,"url":709},"GNU gettext manual","https:\u002F\u002Fwww.gnu.org\u002Fsoftware\u002Fgettext\u002Fmanual\u002Fgettext.html",{"title":711,"url":712},"RFC 5646: Tags for Identifying Languages","https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc5646",{"title":714,"url":715},"Rat für deutsche Rechtschreibung","https:\u002F\u002Fwww.rechtschreibrat.com\u002F",{"title":717,"url":718},"Microsoft Writing Style Guide","https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fwelcome\u002F","\u002Fimages\u002Fblog\u002Fllm-translation-pipeline-i18n\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fllm-translation-pipeline-i18n\u002Fog.jpg",[77,78,79],"Eine LLM-Übersetzungspipeline für i18n: Glossar, Platzhalter und Review","Produkt- und Website-Texte mit LLMs übersetzen, ohne Platzhalter, Pluralformen oder Tonalität zu beschädigen: Glossar, ICU-Nachrichten, QA-Checks, Review.","Drei Quellkarten, eine übersetzte Karte mit Glossar-Lesezeichen, dann eine geprüfte Klemmschiene.",{"slug":726,"published":727,"minutes":728,"category":7,"tags":729,"keywords":733,"about":744,"sources":751,"cover":767,"og":768,"expertise":75,"locales":769,"lang":78,"title":770,"description":771,"coverAlt":772},"nuxt-i18n-multilingual-seo-hreflang","2026-10-16",8,[10,661,730,731,732],"SEO","hreflang","Multilingual",[734,735,736,737,738,739,740,741,742,743],"nuxt i18n seo","nuxt hreflang","useLocaleHead","nuxt i18n canonical","hreflang x-default","prefix_except_default","multilingual seo nuxt","nuxt i18n sitemap hreflang","hreflang self-referencing","nuxt i18n baseUrl",[745,747,750],{"name":10,"url":746},"https:\u002F\u002Fnuxt.com\u002F",{"name":748,"url":749},"Hreflang","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FHreflang",{"name":678,"url":679},[752,755,758,761,764],{"title":753,"url":754},"Nuxt i18n docs: SEO (useLocaleHead)","https:\u002F\u002Fi18n.nuxtjs.org\u002Fdocs\u002Fguide\u002Fseo",{"title":756,"url":757},"Nuxt i18n docs: Routing strategies","https:\u002F\u002Fi18n.nuxtjs.org\u002Fdocs\u002Fguide",{"title":759,"url":760},"Google Search Central: Tell Google about localized versions of your page","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fspecialty\u002Finternational\u002Flocalized-versions",{"title":762,"url":763},"Google Search Central: Managing multi-regional and multilingual sites","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fspecialty\u002Finternational\u002Fmanaging-multi-regional-sites",{"title":765,"url":766},"Google Search Central: How to specify a canonical URL","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fcrawling-indexing\u002Fconsolidate-duplicate-urls","\u002Fimages\u002Fblog\u002Fnuxt-i18n-multilingual-seo-hreflang\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fnuxt-i18n-multilingual-seo-hreflang\u002Fog.jpg",[77,78,79],"Mehrsprachiges SEO mit Nuxt i18n: URL-Strategie, hreflang, Canonicals und Sitemaps","So richtet man Nuxt i18n ein, damit Suchmaschinen jede Sprache indexieren: Routing-Strategie, useLocaleHead, hreflang-Regeln, Teilübersetzungen, Sitemaps.","Ein großes Blatt, durch drei waagrechte Verbindungen an eine Spalte aus drei kleineren Blättern gebunden.",1792245342524]