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.
Balázs Csorba··11 perc olvasás
- Vue
- Nuxt
- Accessibility
- Streaming
- AI chat

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.
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és | Irány | Hitelesítés | Újracsatlakozás | Amit magad kezelsz |
|---|---|---|---|---|
| EventSource (SSE) | csak szerverből kliensbe | GET, nincs kérési törzs | automatikus a retry mezővel | a teljes küldési utat |
| fetch plusz ReadableStream | kérési törzs és válasz-stream | teljes fejlécek és törzs | nincs, te döntöd el | parsing, megszakítás, hibaállapotok |
| AI SDK useChat | POST UI-üzenet-streammel | konfigurálható fejlécek és törzs | a 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.
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.
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
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.