Blog/Webfejlesztés

AI-csevegő UI Vue-ban és Nuxtban: akadálymentes streaming

AI-csevegő Vue-ban vagy Nuxtban: fetch-stream, megszakítás és újrapróbálás, biztonságos markdown, aria-live bejelentések, billentyűzet és reduced motion.

··11 perc olvasás

  • Vue
  • Nuxt
  • Accessibility
  • Streaming
  • AI chat
Üzenetkártya villogó kurzorral, pontozott vonal hullámokká nyílik, mellette pipa.

A lényeg röviden

  • Az EventSource egyirányú értesítésekre készült; aki az üzeneteit POST-tal küldi és fejlécre van szüksége, a fetch választestét olvassa streamként.
  • A megszakítás felhasználói szándék, nem hiba: a részleges válasz megmarad, Stop után soha ne legyen piros a státusz.
  • A modell kimenete nem megbízható bemenet: alapból biztonságos markdown-parser plusz DOMPurify, és sanitizálás után senki ne nyúljon a stringhez.
  • A transzkriptum role=log régióban, az átmeneti állapotok role=status-ban; a befejezett választ jelledd, ne tokenenként.
  • A billentyűzet, a 24 képpontos célméret és a prefers-reduced-motion ugyanahhoz a késznek-minősüléshez tartozik, mint a stream-parser.

Cikk meghallgatása

0:000:00

A csevegődoboz a legkönnyebben demózható LLM-funkció, és az egyik legnehezebb jól kiszállítani. A válasz tokenenként érkezik, a felhasználó a szöveg növekedését nézi, és ebben a körben három külön kötelesség ül: a streamet őszintén tartani, amikor a hálózat hibázik; a modell kimenetét távol tartani a böngésző végrehajtásától; és a felületet használhatóvá tani annak is, aki sosem látja a szöveget megjelenni. Vue- és Nuxt-alkalmazásokat építek osztrák B2B-ügyfeleknek, és minden csevegős review-nál a streaming működik, az akadálymentesség nem.

A következő építési sorrend, nem könyvtárbemutató. Választunk egy transzportot, kiolvasunk belőle egy Vue composable-ban egy readerrel és egy AbortController-rel, négy állapotra modellizzük a kapcsolatot, sanitizáljuk a modell markdownját, majd jön a kimaradó rész: az új tartalom bejelentése képernyőolvasóknak, a szerkesztőbillentyűzet használata billentyűzetről, és a prefers-reduced-motion tiszteletben tartása. A Nuxt server route-ból történő streaming alapjai a korábbi cikkemben vannak, az LLM-funkciók Nuxtban; itt a stream körüli felületre megyünk mélyebben.

Válaszd ki a transzportot, mielőtt a komponenst választod

Két transzport uralja a csevegőfelületeket. A böngésző natív EventSource a server-sent eventeket beszéli: a szerver a text/event-stream tartalmi típussal és a event, data, id és retry mezőkből épített UTF-8 üzenetekkel válaszol, az újracsatlakozást pedig a böngésző intézi. Ennek a rugalmasságnak két éle van egy csevegésben: a kapcsolat egyirányú, tehát nem viszi az előzményeket küldő POST-kérést, az újracsatlakozás pedig automatikus. Hálózati hiba után a böngésző kérdezés nélkül újrapróbálkozik, ami egy egyszeri token-streamnél pont rossz, mert a csendes retry megismételhet egy félredarabolt választ.

Van egy infrastrukturális korlát is, érdemes tudni elköteleződés előtt. HTTP/1.1 alatt a böngészők böngészőnként és tartományonként hatra korlátozzák a nyitott kapcsolatokat, ami a Chrome-ban és a Firefoxban is won't fix státuszt kapott, míg HTTP/2 alatt a párhuzamos streamek számát kétoldalú alkudozás rögzíti, alapértelmezésben 100. Aki még HTTP/1.1-en fejezi be a kapcsolatot, néhány nyitott csevegőfüllel már a plafonon ül.

MegközelítésIrányHitelesítésÚjracsatlakozásAmit magad kezelsz
EventSource (SSE)csak szerverből kliensbeGET, nincs kérési törzsautomatikus a retry mezővela teljes küldési utat
fetch plusz ReadableStreamkérési törzs és válasz-streamteljes fejlécek és törzsnincs, te döntöd elparsing, megszakítás, hibaállapotok
AI SDK useChatPOST UI-üzenet-streammelkonfigurálható fejlécek és törzsa transzport kezeliállapot, renderelés, akadálymentesség

Ha a csevegés üzenetlistát küld, Authorization-fejlécre van szüksége, és a Stop megnyomásakor azonnal le kell állnia, a ReadableStream-ként olvasott fetch választest az őszinte alapértelmezés, ez az, amit az AI SDK useChat hookja is csinál, a Vue-csomagjában is. A cikk többi része ezt a kört kézzel megépíti, hogy amikor a hookhoz nyúlsz, tudd, mit csinál helyetted, és hol ér véget az ő állapotmodellje, és hol kezdődik a te akadálymentességi munkád.

Fetch-stream kiolvasása Vue composable-ban

A fetch válasz a testét ReadableStreamként adja ki. Lezárhatod getReader()-rel és ciklusban olvashatod, vagy kihagyhatod a readert és magát a chunkokat iterálhatod: a Streams API megvalósítja az async iterable protokollt, a for await...of tehát a választesten működik, felkészítés nélkül. Az MDN két, gyakorlatban fontos részletre hívja fel a figyelmet: az iterálás lezárja a streamet, így más nem fogyaszthatja, a ciklus idő előtti elhagyása pedig megszakítja a streamet, hacsak nem preventCancel -t adsz meg. A bájtokhoz vezesd a testet egy TextDecoderStream-en át, a TextDecoder streamelő változatán, amely bináris kódolást alakít át sztring-streammé.

<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>

Két sornak itt van kommentje. A maradék puffer azért kell, mert a hálózati chunkok nincsenek a protokollhoz igazítva: egyetlen data-esemény két read között is kettéválhat, ezért csak a teljes, üres sorokkal elválasztott eseményeket dolgozd fel, a többit hagyd a következő körnek. A catch ág pedig az AbortError-t normális befejezésként kezeli, mert a Stop felhasználói szándék, nem hiba. Minden más, beleértve a stream indulása előtt észlelt nem 2xx státuszt, error állapotba viszi az UI-t, ahol az újrapróbálás felajánlható.

A megszakítás funkció, nem hiba

AbortController az erre való szabványeszköz: az abort() metódusa megszakítja a fetch kéréseket, a választestek és stream-ek fogyasztását. Tegyél egy controllert beszélgetésenként, az előző kérést szakítsd meg az új indítása előtt, és unmountnál is szakítsd meg, hogy egy hosszú generálás közbeni navigáció ne hagyja a szervert egy halott fülbe streamelni. Az finom rész a felület körül van: a már megérkezett részleges válasz érvényes szöveg, tartsd meg a transzkriptumban, jelöld a üzenetet megállítottnak, és soha ne legyen piros az állapot.

// 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)
}

Az újrapróbálás normál gombként tartozik a folyamatba, amely megjelenéskor fókuszba kerül, nem rejtett kézmozdulatként. Az AI SDK pontosan ezt a párost adja: a stop megszakítja a fetch kérést, a regenerate a modellt a legutóbbi üzenet újrafeldolgozására kéri, a status pedig eldönti, melyik aktív. Ha valóban hiba történik, rövid általános üzenetet mutass: az AI SDK dokumentációja egy egyszerű valami-gált üzenetet ajánl a nyers hiba helyett, hogy a szerver részletei k maradjanak ki a nyilvános felületről, a valódi hiba pedig a naplódba kerül, ahol tehetsz ellene.

Csevegő-stream életciklusaA szerkesztő POST kérést küld, a szerver token-chunkokat streamel vissza, a kliens hozzáfűzi és sanitizálja őket, a stream a kész állapotban ér véget; az Stop út megszakítja a fetchet, az hiba út újrapróbálást kínál.ComposerküldésPOST/api/chatStreamtoken-chunkokRendersanitizálásKészready állapotStopabort()Hibaretry
Két kijárat számít: a Stop-nak az állapotgépben megkülönböztethetetlennek kell lennie a sikertől, a hálózati hibának pedig újrapróbáláson kell landolnia, nem stacktrace-en.

A kapcsolat négy állapotként modellje

Minden látható vezérlőt egyetlen státuszértékből vezess le. Az AI SDK useChat hookja négy értékű státuszt ad: a submitted azt jelenti, hogy a kérés úton van és a stream még nem indult el, a streaming hogy chunkok érkeznek, a ready hogy a válasz elkészült és új üzenet küldhető, a error pedig hogy a kérés meghibásodott. A küldés gomb csak ready-n kívül legyen letiltva, Stop csak submitted vagy streaming alatt, újrapróbálás csak a hibaállapotban: ez egy állapotgép, amit képernyőolvasó nélkül is le tudsz tesztelni, és ez az a gép, amit az akadálymentesítő technika a status régiódon keresztül tud olvasni.

A throttle az állapotmodellhez tartozik, nem a teljesítmény-háttérlistához. A useChat dokumentál Vue-hoz egy throttle opciót, amely a reaktív üzenet-frissítéseket csomagolja (a dokumentáció 50 milliszekundumot használ), miközben a stream feldolgozása és az event callbackek azonnaliak maradnak, és garantálja, hogy a legutolsó üzenetek még a terminális állapot elérése előtt kikerülnek. Nélküle egy gyors modell tucatszor mutálja a reaktív tömbödet másodpercenként, és minden fogyasztó vele együtt renderel.

A Vue a renderelési oldalon segít: reaktív állapot módosításakor a DOM-frissítések nem szinkronusan mennek végre, hanem a következő tickig vannak pufferelve, így több chunk-írás egy tickben egyetlen patchbe olvad. Ne mérd a transzkript DOM-ját abban a tickben, amelyikben hozzáfűztél egy chunkot; előbb várd meg a nextTick()-et. Itt találkozik az akadálymentességi és a teljesítmény-keret is, mert kevesebb render lenyomkenti az oldal interaction next paint-jét, erről szól a Core Web Vitals útmutató Nuxt-oldalakhoz.

Markdown renderelése a DOM átadása nélkül

A modell kimenete felhasználók által befolyásolt bemenet. Egy prompt megkérheti a modellt, hogy script taget, iframe-et vagy javascript linket adjon ki, aki pedig a beszélgetést irányítja, megpróbálhatja valóban: ez ugyanaz a fenyegetési modell, mint bármely más nem megbízható HTML-nél. A biztonságos kombó egy alapból biztonságos markdown-parser plusz egy sanitizer a kimenetén. Pontosan ezt hirdeti a markdown-it: CommonMark-követő és alapból biztonságos, a nyers HTML tehát escape-elve marad, hacsak nem kérsz külön. Aztán a DOMPurify eltakarítja, amit a parser legyártott.

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

// html false marad: a modell kimenetében lévő nyers HTML escape-elve lesz
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 }, // a válaszhoz nem kell SVG vagy MathML
    ADD_ATTR: ["target"],
  })
}

Tartsd szűken az allowlistét és egyirányú a pipeline-t: markdown be, sanitizált HTML ki, és csak azután v-html-jal renderelve. A hivatkozások a target és rel attribútumaikat a te rendereredtől kapják, sosem a modelltől, ami pedig eszközhívásnak vagy termékkártyának néz ki, azt egy általad kontrollált Vue-komponens rajzolja. A képernyőolvasó is találkozik ezzel a tartalommal, tartsd tehát szemantikusnak a generált markupot: valódi fejlécek, valódi listák, valódi code elemek, az akadálymentesítő technika ugyanis pontosan ezekkel a struktúrákkal navigál.

A választ jelentsd be, ne minden tokent

Ez a rész, amit a legtöbb implementáció kihagy. A WCAG 2.2 2023. október 5-én jelent meg W3C-ajánlásként, és a 4.1.3 Status Messages sikerkritériuma (AA szint) megköveteli, hogy a státuszüzenetek szerepen vagy tulajdonságokon keresztül programozottan meghatározhatók legyenek, hogy az akadálymentesítő technika fókuszvétel nélkül jeleníthesse meg őket. A WAI technikái a role=status-t nevezik meg eredményekhez és várakozási állapotokhoz, a role=log-t folyamatos frissítésekhez, a role=alert-et hibákhoz. A csevegő-transzkriptum a role=log tipikus esete: az MDN élő régióként írja le, ahová az új információ értelmes sorrendben, a végére érkezik, és egy beszélgetés is éppen így nő.

Három részlet dönti el, hogy ez hogyan érződik. A role=log régió implicit aria-live értéke polite, a bejelentések tehát megvárnak, amíg a felhasználó tétlen, az implicit aria-atomic pedig false, így csak a különbség hangzik el. A régiónak hozzáférhető név kell, tehát aria-label vagy aria-labelledby szükséges. És a transzkriptban ott a kész válasz, mert a képernyőolvasós felhasználó, aki később rátalál a logra, olvasható szövegként keresi a választ.

Bejelentési pipelineA nyers tokenek mondatnyi darabokba bufferelődnek, egyszer kerülnek egy polite log régióba, és a képernyőolvasó akkor mondja el őket, amikor a felhasználó tétlen.Tokeneknyers streamPuffermondatnyiLog régiópoliteDeltaegyszer jelentveOlvasóbeszéd tétlenben
A puffer az akadálymentességi funkció: az olvasó egy kész választ hall, nem ötven token-frissítést.

Tartsd kicsin a bejelentési felületet: egy státuszvonal az átmeneti állapotoknak (gondolkodik, megállítva, hiba), és a log régió a tartalomnak. Mert a polite live régiók csak a változást jelentik, az assztisztens-üzenet streamzárás utáni egyszeri közzététele tiszta egyetlen bejelentést ad, míg minden token-szakasz dadogást okozna. A WAI 4.1.3-hoz szóló understanding dokumentuma a másik irányból is figyelmeztet: egy alkalmazás túl beszédes is lehet, ezért a bejelentések ritmusát valódi képernyőolvasós felhasználókkal teszteld, ne tippelj.

<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>

Billentyűzet és fókusz: a szerkesztő az alkalmazás

Egy csevegésben mindennek működnie kell egyedül billentyűzetről, és a szerkesztőnél szokott ez elromlani. A küldés után maradjon a fókusz a textarea-ban, hogy aki kérdést kérdésre lő, ne ugráljon vissza Tabbal. Az Enter küld, a Shift+Enter újsor, de a billentyűt csak a textarea-n magán szabad elkapni, sosem egy burkoló diven. A Send és a Stop valódi gomb, az Enter és a Space tehát ingyen működik, a stream közepén megjelenő Stop pedig nem veheti el a fókuszt attól, aki még gépel. Semmi ne legyen elérhető csak hoverrel.

Két WCAG 2.2-es kritérium pontosan ehhez a elrendezéshez szól. A Target Size (Minimum), 2.5.8 AA szinten 24 ször 24 CSS-képpontos mutatócélokat vagy elegendő teret kér a kisebbek köré, ami elveszi a kísértést, hogy a Stopot apró inline jelként rajzold. A Focus Not Obscured (Minimum), 2.4.11 AA szinten megköveteli, hogy a billentyűzőfókuszt kapó komponens ne legyen teljesen eltakarva szerzői tartalommal, és ez a görgetett transzkript feletti ragadós szerkesztősáv pontos kockázata: adj hozzá scroll paddingot és scroll margókat, hogy a üzenetekben lévő linkek fókuszba kerülve látszódjanak.

Tiszteld a prefers-reduced-motion-t

prefers-reduced-motion azt az beállítást érzékeli, amelyet az emberek a nem lényeges mozgás minimalizálására kapcsolnak be, az MDN pedig a nagy objektumok skálázását és pásztázását nevezi meg klasszikus rosszullét-kiváltóként vestibuláris mozgászavaroknál. A csevegés tele van pontosan ezekkel: pulzáló gépelőpontok, villogó kurzor, gördülő auto-scroll, amely minden chunkkal feljebb tolja a transzkriptot. A javítás olcsó, és CSS: tartsd meg az animációt azoknak, akik akarják, mindenki másnak pedig cseréld azonnali állapotváltásra.

.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; }
}

Két vélemény, amit érdemes megvédeni egy review-n. Egy: csökkentett mozgásnál az auto-scroll ugráljon, ne csússzon, és álljon meg teljesen, amint a felhasználó felfelé görget, hogy olvasson valamit, mert az olvasóval szembeni küzdelem bármelyik rosszabb animációnál. Kettő: a tevékenység egyetlen jelét soha ne rejtsd mozgás mögé; ha a gépelésjelző az egyetlen bizonyíték, hogy a modell dolgozik, a csökkentett mozgású felhasználónak egyáltalán nincs bizonyítéka. Ha a preferenciára szerveroldalon is szükséged van, van hozzá user-agent client hint: Sec-CH-Prefers-Reduced-Motion.

A csevegő UI-t felhasználóként teszteld, nem linterként

Úgy tesztelj, ahogy a hiba a valós felhasználóhoz eljut. Egy kör csak billentyűzettel: Tab a szerkesztőbe, Enter a küldéshez, ellenőrizd, hogy a Stop megjelenik és elérhető a stream közben, az újrapróbálás pedig fókuszba kerül, amikor előjön. Egy kör képernyőolvasóval: VoiceOver macOS alatt és NVDA Windows alatt, az kettő, amely az osztrák és magyar ügyfeleimnél számít: a választ egyszer kellene bejelenteni, a hibát egyáltalán, és semminek nem szabad megismételnie magát tokenek közben. Futtass automatikus axe scan-t a CI-ben a hiányzó nevekért és szerepekért, de kezeld padlónak, nem bizonyítéknak.

Aztán add hozzá azt a három regressziós tesztet, amit hajnal kettőkor szeretnél. Egy: szakítsd meg a kapcsolatot a stream közepén, és ellenőrizd, hogy a UI hibaállapotba kerül újrapróbálási lehetőséggel és kezelt ígérettel. Kettő: nyomd meg a Stopot, és ellenőrizd, hogy az állapot nem error, a részleges válasz pedig túléli. Három: küldj olyan üzenetet, amitől a modell script taget ad ki, és ellenőrizd, hogy szövegként jelenik meg. Ezek a három teszt lefedik a cikk javát, és bármelyik headless böngészőben futnak. Ha az EU-ban értékesítesz, az European Accessibility Act már most is üzleti követelményként kezeli a hozzáférhető felületeket, ez a munka tehát szintén nem opcionális.

Források

  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

Gyakori kérdések

A streamelő szöveg maga legyen aria-live régióban?

Nem. A kész üzenet kerüljön a log régióba, az átmeneti állapotok (gondolkodik, megállítva, hiba) pedig egy kis status vonalra. A token-frissítéseket kapó live régió a képernyőolvasót dadogó géppé alakítja; a közlést a befejezéskor vagy mondatvégeken add le, a transzkriptum pedig tartsa a teljes szöveget az olvasó számára.

Miért ne az EventSource-t használnám a válaszhoz?

Mert egyirányú, és az újracsatlakozás automatikus. Az EventSource csak fogad, az üzenetlistát küldő POST-hoz tehát amúgy is kell a fetch, a beépített retry pedig csendben lejátszhatja újra a félredarabolt választ. A fetch választest egy hívásban hozza a kérést és a választ, a megszakítás pedig a kezedben marad.

A markdown-it önmagában nem elég?

Alapból escape-eli a nyers HTML-t, de a sanitizer olcsó biztosítás. A linkelt URL-ek, a bővítmények kimenete és a jövőbeli opciók tágíthatják a felületet, a DOMPurify pedig a parser-mutációs, névtér- és clobbering-trükköket is kivédi, amiket a kézzel írt szűrők elnéznek. Futtasd utoljára, és utána ne módosítsd a stringet.

Hogyan maradjon reszponzív az UI tokeneken közben?

Reaktív frissítések csomagolásával. Az AI SDK Vue-hoz throttle opciót ad, Vue amúgy is a következő tickig puffereli a DOM-patcheket, elég tehát elkerülni a nehéz gyerek-komponenseket és a scroll-számítást ugyanabban a tickben. A kevesebb render a page interaction next paint-jét is védi.

Tényleg képernyőolvasóval kell tesztelnem?

Igen, és olcsóbban, mint félsz. Öt perc VoiceOver-rel macOS alatt vagy NVDA-val Windows alatt megmutatja a két klasszikus hibát: a soha be nem jelentett transzkriptet és a vizuálisan nyilvánvaló, de néma hibaállapotot. Az automatikus scan-ek megtalálják a hiányzó szerepeket; azt nem mondják meg, használhatók-e a bejelentések.

Pont erre van szükséged?

Írj a projektedről vagy a pozícióról – szívesen hallok felőled.