IlmHamroh
JavaScript Full-stack/21-qism. React: media, integratsiyalar, PWA26/32-dars25 daqiqa
Mundarija (33)

AI chat interfeysi React'da: oqimli javob, SSE, to'xtatish va qayta yaratish

Qisqacha: AI javobi bir martada emas, bo'lakma-bo'lak keladi — shuning uchun chat uni kelishi bilan chizadi. Brauzer fetch ning response.body oqimini o'qiydi, server esa matnni SSE (Server-Sent Events) shaklida data: … qatorlari bilan yuboradi. «To'xtatish» — AbortController, «Qayta yaratish» — oxirgi savolni qayta yuborish. API kalit hech qachon brauzerga tushmaydi: modelga faqat server so'raydi. vazifalar da yordamchi dev serverdagi soxta oqim bilan ishlaydi; production'da u o'chiq va kodi kech chunk'da.

Bu darsda

  • fetch javobini oqim bilan o'qib, matnni kelishi bilan chizasiz.
  • SSE formatini (data:, event:, bo'sh qator) o'zingiz ajratasiz va nega EventSource emas, fetch ekanini tushuntirasiz.
  • «To'xtatish», «Qayta yaratish», avto-aylantirish va oqimdagi Markdown'ni React'da yozasiz.
  • Vercel AI SDK useChat nima berishini va uning narxini bilasiz.
  • API kalit nega faqat serverda turishi kerakligini hujumchi ko'zi bilan ko'rasiz.

Oldin bilishingiz kerak: Streams API, WebSocket va SSE, Real-time frontendda, Markdown render qilish, Bekor qilish: AbortController, Feature flag va A/B testlar.

1. Nega bu kerak?

Jasur aka «Bahor» ilovasiga yordamchi qo'shmoqchi: mehmon «Bugun nima yesam bo'ladi?» deb yozadi, AI javob beradi. Muammo — tezlik. Katta til modeli (LLM — Large Language Model, matn yozadigan AI) javobni so'zma-so'z hosil qiladi. To'liq javob 5–10 soniyada tayyor bo'ladi.

Agar ilova javobni to'liq kutsa, mehmon 10 soniya bo'sh ekranga qaraydi va «ishlamayapti» deb o'ylaydi. Agar har so'z kelishi bilan chizilsa — birinchi so'z yarim soniyada ko'rinadi, matn ko'z oldida "yozilib" boradi. Umumiy vaqt bir xil, lekin his butunlay boshqa.

Hayotiy o'xshatish: choyxonachi. Bir xili choynakni to'liq damlab, keyin olib keladi. Boshqasi piyolani darhol qo'yib, choyni quya boshlaydi. Ikkinchisini kutish oson.

Bu darsda shu "quyib borish"ni quramiz: server oqimi, brauzerda o'qish, React'da chizish. Keyin chat interfeysining odatiy tugmalari — to'xtatish, qayta yaratish, pastga aylantirish. Oxirida vazifalar ga «Yordamchi» sahifasi qo'shiladi.

2. Oqim: javob bo'laklarda keladi

2.1 response.body — eslab olamiz

Streams API darsida ko'rdik: fetch javobining tanasi — ReadableStream. await response.json() uni oxirigacha kutadi. response.body.getReader() esa har bo'lakni kelishi bilan beradi.

Chat uchun aynan ikkinchisi kerak. Lekin bo'laklar qanday shaklda keladi? Server "Osh", " uchun" deb xom matn yuborsa ham bo'lardi. Amalda deyarli hamma AI API'lari SSE formatini ishlatadi.

2.2 SSE formati — uch qoida

SSE (Server-Sent Events) — server brauzerga hodisalarni matn qatorlari bilan yuboradigan oddiy format. WebSocket va SSE darsida uni EventSource bilan o'qigan edik. Formatning o'zi uch qoidadan iborat:

  1. Har hodisa — bir nechta qator, oxirida bo'sh qator (\n\n).
  2. data: … — hodisaning ma'lumoti. Bir nechta data: qatori bo'lsa — ular \n bilan qo'shiladi.
  3. event: nom — hodisa turi. Bo'lmasa — message. : bilan boshlangan qator — izoh (masalan, aloqa uzilmasin deb yuboriladigan "ping").

«Bahor» yordamchisining javobi tarmoqda shunday ko'rinadi:

text
data: {"matn":"Osh "}

data: {"matn":"uchun "}

event: tugadi
data: {}

2.3 Nega EventSource emas?

EventSource SSE'ni o'zi ajratadi — qulay. Lekin uning ikki cheklovi chat uchun hal qiluvchi:

  • faqat GET so'rov yuboradi, tana (body) yo'q. Chat esa butun suhbat tarixini POST bilan yuboradi;
  • sarlavha (header) qo'sha olmaysiz — masalan, Authorization.

Shuning uchun AI chatlarda deyarli doim fetch + response.body + o'z SSE ajratgichi ishlatiladi. Kanon fayl boshidagi izohda aynan shuni yozgan.

2.4 Haqiqiy oqim: server va brauzer bitta faylda

Quyidagi blok Node'da ishlaydi. U kichik HTTP server ochadi, har 50 ms da bitta so'z yuboradi va o'sha zahoti fetch bilan o'zini so'raydi. Server qismi 25-qismda chuqur o'rganiladi — hozir faqat res.write (bo'lak yuborish) va res.end (tugatish) ga qarang:

js
import { createServer } from "node:http";

const jurnal = [];
const server = createServer((req, res) => {
  req.on("data", () => {});
  req.on("end", () => {
    res.writeHead(200, { "Content-Type": "text/event-stream" });
    const sozlar = ["Osh ", "uchun ", "guruch ", "kerak."];
    let i = 0;
    const taymer = setInterval(() => {
      if (i === sozlar.length) {
        clearInterval(taymer);
        res.end("event: tugadi\ndata: {}\n\n");
        return;
      }
      res.write(`data: ${JSON.stringify({ matn: sozlar[i] })}\n\n`);
      i++;
    }, 50);
    req.on("close", () => jurnal.push(`req close: ${i} ta`));
    res.on("close", () => jurnal.push(`res close: ${i} ta`));
  });
});

server.listen(0, async () => {
  const { port } = server.address();
  const javob = await fetch(`http://localhost:${port}/chat`, {
    method: "POST",
    body: JSON.stringify({ savol: "Osh uchun nima kerak?" }),
  });
  console.log(javob.headers.get("content-type"));
  const oquvchi = javob.body
    .pipeThrough(new TextDecoderStream())
    .getReader();
  for (let n = 1; ; n++) {
    const { value, done } = await oquvchi.read();
    if (done) break;
    console.log(n, JSON.stringify(value));
  }
  console.log(jurnal.join("\n"));
  server.close();
});

Konsolda:

text
text/event-stream
1 "data: {\"matn\":\"Osh \"}\n\n"
2 "data: {\"matn\":\"uchun \"}\n\n"
3 "data: {\"matn\":\"guruch \"}\n\n"
4 "data: {\"matn\":\"kerak.\"}\n\n"
5 "event: tugadi\ndata: {}\n\n"
req close: 0 ta
res close: 4 ta

Uch narsaga qarang:

  1. Beshta read() — beshta bo'lak, har biri o'z vaqtida keldi. JSON.stringify(value) \n larni ko'rinadigan qildi.
  2. TextDecoderStream — baytlarni matnga aylantiradi. Oddiy new TextDecoder().decode(bolak) dan farqi: o'zbekcha yoki emoji harfi ikki bo'lakka bo'linib kelsa ham buzmaydi.
  3. Jurnal. req ning close hodisasi birorta so'z yuborilmasdan keldi (0 ta). res niki esa oxirida (4 ta).

Uchinchisi — kanon topgan haqiqiy xato. Soxta AI serveri avval req.on("close") da taymerni to'xtatardi: "brauzer to'xtatsa, biz ham to'xtaymiz". Lekin Node 16 dan beri req ning close i so'rov tanasi o'qib bo'lingach darhol keladi. Natijada oqim boshlanishi bilan o'chib qolardi. To'g'risi — res.on("close"): javob ulanishi yopilganda (tugadi yoki brauzer uzdi). Kanon izohi:

js
// Brauzer to'xtatdi (AbortController) — oqim ham to'xtaydi. req'ning
// "close" i EMAS: u tana o'qib bo'lingach darhol keladi (Node 16+)
res.on("close", () => clearInterval(taymer));

Tekshirib ko'ring: Nega serverga "Content-Type": "text/event-stream" sarlavhasi kerak, agar brauzer baribir fetch bilan o'qisa?

Javob

Bizning fetch kodimiz uchun u shart emas — biz baytlarni o'zimiz o'qiymiz. Lekin uchta sabab bor. Birinchisi — kelishuv: EventSource va AI SDK'lar shu turni kutadi. Ikkinchisi — oraliq serverlar (proksi, nginx) bu turni ko'rib javobni yig'ib turmaydi, darhol uzatadi. Uchinchisi — DevTools Network panelida «EventStream» tabi chiqadi va hodisalarni alohida ko'rsatadi.

3. SSE ajratgich: bo'laklardan hodisalar

3.1 Bo'lak ≠ hodisa

Yuqorida har bo'lak bitta hodisaga to'g'ri keldi. Bu omad. Haqiqiy tarmoqda TCP bo'laklarni istalgan joyda bo'lishi yoki birlashtirishi mumkin: "da", "ta: sal", "om\n", "\n". Shuning uchun ajratgich kelgan matnni qoldiqda yig'adi va faqat to'liq hodisani (\n\n gacha) beradi.

Kanon oqim.ts — kod aynan (fayl boshidagi izoh qisqartirildi, ikki izoh qatori telefonga sig'ishi uchun bo'lindi). U asinxron generator: har hodisani yield qiladi, chaqiruvchi for await bilan oladi:

ts
export type OqimHodisasi = {
  readonly tur: string; // "message" (sukut) yoki "event:" dagi nom
  readonly malumot: string;
};

/** Baytlar oqimi → hodisalar (bo'laklar istalgan joyda
 *  uzilishi mumkin) */
export async function* sseHodisalari(
  // response.body ham shu turga mos
  oqim: ReadableStream<BufferSource>,
): AsyncGenerator<OqimHodisasi> {
  // TextDecoderStream — UTF-8 harf ikki bo'lakka
  // bo'linsa ham to'g'ri
  const oquvchi = oqim.pipeThrough(new TextDecoderStream()).getReader();
  let qoldiq = "";
  try {
    for (;;) {
      const { value, done } = await oquvchi.read();
      if (done) {
        break;
      }
      qoldiq += value.replaceAll("\r\n", "\n");
      let chegara = qoldiq.indexOf("\n\n");
      while (chegara !== -1) {
        const blok = qoldiq.slice(0, chegara);
        qoldiq = qoldiq.slice(chegara + 2);
        let tur = "message";
        const malumot: string[] = [];
        for (const qator of blok.split("\n")) {
          if (qator.startsWith("event:")) {
            tur = qator.slice(6).trim();
          } else if (qator.startsWith("data:")) {
            malumot.push(qator.slice(5).replace(/^ /, ""));
          }
        }
        if (malumot.length > 0 || tur !== "message") {
          yield { tur, malumot: malumot.join("\n") };
        }
        chegara = qoldiq.indexOf("\n\n");
      }
    }
  } finally {
    oquvchi.releaseLock();
  }
}

// Sinov: bo'laklar hodisa o'rtasida va emoji o'rtasida uzilgan
const baytlar = new TextEncoder().encode(
  'data: {"matn":"Osh 🍚"}\n\nda' + "ta: a\ndata: b\r\n\r\n" +
    ": ping\n\nevent: tugadi\ndata: {}\n\n",
);
const oqim = new ReadableStream<BufferSource>({
  start(k) {
    k.enqueue(baytlar.slice(0, 20)); // 🍚 ning 4 baytidan 2 tasi
    k.enqueue(baytlar.slice(20, 30));
    k.enqueue(baytlar.slice(30));
    k.close();
  },
});
for await (const h of sseHodisalari(oqim)) {
  console.log(h.tur, JSON.stringify(h.malumot));
}

Konsolda:

text
message "{\"matn\":\"Osh 🍚\"}"
message "a\nb"
tugadi "{}"

Bo'laklar ataylab noqulay joylarda kesildi: 20-baytda emoji ikkiga bo'lindi, 30-baytda da va ta: ajraldi. Natija baribir uchta to'g'ri hodisa. : ping izohi tashlandi, \r\n (Windows qator oxiri) \n ga aylandi, ikki data: qatori "a\nb" bo'lib qo'shildi.

finally dagi releaseLock() muhim: foydalanuvchi «To'xtatish» ni bossa, for await siklidan chiqiladi va generator yopiladi. O'quvchi qulfni qaytaradi — oqim boshqa joyda ishlatilishi yoki bekor qilinishi mumkin.

Kanonda bu funksiya uchun 4 ta node:test bor: CRLF va ko'p qatorli data, bo'laklar orasida uzilgan hodisa, ikki bo'lakka bo'lingan kirill harfi, izoh va yopilmagan hodisa.

3.2 Hodisadan matnga

Ajratgich hodisa beradi, chat esa matn so'raydi. Kanon YordamchiSahifasi.tsx dagi sikl — aynan:

tsx
for await (const h of sseHodisalari(javob.body)) {
  if (h.tur === "tugadi") {
    break;
  }
  const bolak = (JSON.parse(h.malumot) as { matn: string }).matn;
  oxirgisiniYangila((x) => ({ matn: x.matn + bolak }));
}

Har data — JSON, ichida matn. Har bo'lak oxirgi xabarga qo'shiladi va React qayta chizadi. Har AI provayderning JSON shakli boshqacha (choices[0].delta.content, delta.text …). Shuning uchun real loyihada o'z serveringiz uni bitta shaklga keltirib beradi — brauzer kodi provayderga bog'lanmaydi.

Tekshirib ko'ring: Ajratgich qoldiq.indexOf("\n") (bitta qator oxiri) bo'yicha bo'lsa nima buzilardi?

Javob

Bitta hodisa bir nechta qatordan iborat bo'lishi mumkin: event: tugadi va data: {}. Bitta \n bo'yicha bo'lsak, event: qatori data: dan ajralib, alohida "hodisa" bo'lib qoladi — turi yo'qoladi. Ikki data: qatorini qo'shish ham buziladi. Hodisa chegarasi — bo'sh qator, ya'ni \n\n.

4. React'da chat: to'xtatish va qayta yaratish

4.1 Jonli model

Natija oynasida tarmoq yo'q, shuning uchun serverni soxta oqim bilan almashtiramiz: asinxron generator har 40 ms da bitta so'z beradi. Komponentning qolgani — haqiqiy chatdagidek. Matnni yozing, «Yuborish» ni bosing va so'zlar paydo bo'lishini kuzating:

jsx
import { useRef, useState } from "react";
import Markdown from "react-markdown";

const JAVOB =
  "**Osh** uchun:\n\n1. Zirvak qiling\n2. Guruch soling\n3. Damlang";

// Soxta oqim: javob so'zma-so'z, har 40 ms da bittadan
async function* soxtaOqim(signal) {
  for (const soz of JAVOB.split(/(?<=\s)/)) {
    await new Promise((r) => setTimeout(r, 40));
    if (signal.aborted) return;
    yield soz;
  }
}

export default function App() {
  const [savol, setSavol] = useState("");
  const [javob, setJavob] = useState("");
  const [holat, setHolat] = useState("bo'sh");
  const pult = useRef(null);

  async function yubor(event) {
    event.preventDefault();
    const p = new AbortController();
    pult.current = p;
    setJavob("");
    setHolat("yozmoqda");
    let bolaklar = 0;
    for await (const soz of soxtaOqim(p.signal)) {
      bolaklar++;
      setJavob((eski) => eski + soz);
    }
    setHolat(p.signal.aborted ? "to'xtatildi" : "tayyor");
    console.log(`bo'laklar: ${bolaklar}`);
  }

  return (
    <form onSubmit={yubor}>
      <label htmlFor="savol">Savolingiz</label>{" "}
      <input
        id="savol"
        value={savol}
        onChange={(e) => setSavol(e.target.value)}
      />{" "}
      <button disabled={holat === "yozmoqda"}>Yuborish</button>{" "}
      {holat === "yozmoqda" && (
        <button type="button" onClick={() => pult.current.abort()}>
          To'xtatish
        </button>
      )}
      <Markdown>{javob}</Markdown>
      <p role="status">Holat: {holat}</p>
    </form>
  );
}

Ekranda (oqim tugagach):

text
Savolingiz Yuborish
Osh uchun:
Zirvak qiling
Guruch soling
Damlang
Holat: tayyor

Konsolda:

text
bo'laklar: 11

Qadamma-qadam:

Qadam holat Ekranda
boshida bo'sh «Yuborish» faol, javob yo'q
«Yuborish» bosildi yozmoqda «Yuborish» o'chiq, «To'xtatish» bor, matn so'zma-so'z o'sadi
11-bo'lak keldi tayyor «To'xtatish» yo'qoldi, «Holat: tayyor»

Kalit joylar:

  • setJavob((eski) => eski + soz) — funksional yangilash. javob + soz deb yozilsa, sikl birinchi renderdagi bo'sh javob ni yopib oladi (eskirgan closure) — har safar faqat oxirgi so'z qolardi.
  • useRef dagi AbortController — «To'xtatish» bosilganda abort(). Uni state'ga qo'ymaymiz: o'zgarishi ekranni qayta chizishi kerak emas.
  • disabled — javob yozilayotganda ikkinchi savol yuborilmaydi. Aks holda ikki oqim bitta matnga aralashadi.

4.2 «To'xtatish» qanday ishlaydi

Haqiqiy fetch da signal so'rovga beriladi: fetch(url, { signal }). abort() chaqirilganda ikki narsa bo'ladi: brauzer ulanishni uzadi, reader.read() esa AbortError bilan rad etiladi. Server tomonda res ning close hodisasi keladi — soxta server taymerini shu yerda to'xtatadi.

Kanon xatoni va to'xtatishni bitta catch da ajratadi — signal bekor qilinganmi:

tsx
} catch (xato) {
  const toxtadi = yangiPult.signal.aborted;
  oxirgisiniYangila(() => ({
    holat: toxtadi ? "toxtatildi" : "xato",
  }));
  setElon(
    toxtadi
      ? "Javob to'xtatildi."
      : `Javob kelmadi: ${xato instanceof Error ? xato.message : String(xato)}`,
  );
}

To'xtatilgan javob o'chirilmaydi — yozilgan qismi qoladi, ostida «(to'xtatildi)». Foydalanuvchi kerakli joyni allaqachon o'qigan bo'lishi mumkin.

Yana bir joy — sahifadan chiqish. Foydalanuvchi boshqa sahifaga o'tsa, oqim fonda davom etmasin. Kanon tozalash effekti:

tsx
// Sahifadan chiqildi — yo'ldagi javob to'xtasin
useEffect(() => () => pult.current?.abort(), []);

4.3 «Qayta yaratish»

AI bir savolga har safar boshqacha javob beradi. Javob yoqmasa — «Qayta yaratish»: oxirgi javob olib tashlanadi va o'sha savol qayta yuboriladi. Kanon:

tsx
function qaytaYarat() {
  // Oxirgi javob olib tashlanadi, o'sha savol qayta yuboriladi
  const tarix = xabarlar.slice(0, -1);
  if (tarix.at(-1)?.rol === "foydalanuvchi") {
    void sora(tarix);
  }
}

Diqqat qiling: serverga butun tarix ketadi (xabarlar: tarix.map(…)), faqat oxirgi savol emas. Model avvalgi suhbatni eslamaydi — har so'rovda kontekstni biz beramiz. Tugma faqat oxirgi xabar yordamchiniki va oqim tugagan bo'lsa ko'rinadi.

Tekshirib ko'ring: Foydalanuvchi oqim o'rtasida «To'xtatish» ni bosdi va darhol yangi savol yubordi. Kanon kodida eski oqimning kech kelgan bo'lagi yangi javobga qo'shilib qolishi mumkinmi?

Javob

Yo'q. Uch himoya bor. abort() dan keyin eski fetch o'qishi xato bilan tugaydi — yangi bo'lak kelmaydi. Har so'rov o'z yangiPult ini yaratadi va catch aynan o'shaning signalini tekshiradi. «Yuborish» esa yozmoqda holatida o'chiq — eski javob holati toxtatildi ga o'tmaguncha yangi savol ketmaydi.

5. Chat UI naqshlari

5.1 Avto-aylantirish — lekin majburlamay

Javob uzaygan sari pastga tushib borish kerak. Lekin foydalanuvchi yuqoriga aylantirib eski xabarni o'qiyotgan bo'lsa, uni har so'zda pastga "sudrash" — jahl chiqaradi. Kanon qoidasi: faqat foydalanuvchi pastda bo'lsa aylantiramiz.

tsx
// Har yangi bo'lakdan keyin: foydalanuvchi pastda bo'lsa — pastga
useEffect(() => {
  if (pastdami.current) {
    royxat.current?.lastElementChild?.scrollIntoView({
      block: "end",
    });
  }
}, [xabarlar]);
useEffect(() => {
  const kuzat = () => {
    pastdami.current =
      innerHeight + scrollY >=
      document.documentElement.scrollHeight - 40;
  };
  addEventListener("scroll", kuzat, { passive: true });
  return () => removeEventListener("scroll", kuzat);
}, []);

pastdami — useRef, state emas: aylantirish har piksel ekranni qayta chizmasin. 40 px — "deyarli pastda" chegarasi. Yangi savol yuborilganda pastdami.current = true — foydalanuvchi o'z savolining javobini ko'rishni xohlaydi. passive: true — aylantirishni sekinlashtirmaydigan tinglovchi (asosiy thread'ni bo'shatish).

5.2 Ekran o'quvchi: har so'z emas

Malika ekran o'quvchida chat ishlatadi. Agar javob matni aria-live hududida bo'lsa, ekran o'quvchi har yangi so'zni o'qishga urinadi — tushunib bo'lmaydigan shovqin. Kanon yechimi ikki qism:

  • yozilayotgan xabarda aria-busy={true} — "bu element hali o'zgaryapti";
  • alohida role="status" qatori faqat oxirida e'lon qiladi: «Javob tayyor.», «Javob to'xtatildi.» yoki «Javob kelmadi: …».

Tayyor javobni Malika ro'yxatda o'zi o'qiydi. Har xabar boshida ko'rinmas «Siz: » yoki «Yordamchi: » — kim gapirgani aniq (ko'rinmas matn).

Klaviatura: Enter — yuborish, Shift+Enter — yangi qator. Chatlardagi odat. Kanon buni requestSubmit() bilan qiladi — forma odatdagi submit yo'lidan o'tadi.

5.3 Oqimdagi Markdown

AI javobi odatda Markdown: **qalin**, ro'yxat, kod. Uni Markdown darsidagi react-markdown chizadi. Oqimning o'ziga xos muammosi bor: yarim kelgan matn yarim Markdown. Ikki holatni yonma-yon ko'ring:

jsx
import Markdown from "react-markdown";

export default function App() {
  return (
    <>
      <Markdown>{"**Osh tay"}</Markdown>
      <Markdown>{"**Osh tayyor**"}</Markdown>
    </>
  );
}

Ekranda:

text
**Osh tay
Osh tayyor

Yopilmagan ** bir lahza yulduzchalar bilan ko'rinadi, yopilgach qalin bo'ladi. Bu normal va qisqa. Yo'qotish uchun maxsus "oqimli Markdown" kutubxonalari bor — ular yopilmagan belgilarni vaqtincha yopadi. Kanon oddiy yo'lni tanladi: har bo'lakda butun matn qayta chiziladi.

Uchta eslatma:

  • Narx. Har so'zda butun Markdown qayta tahlil qilinadi. 500 so'zlik javobda bu sezilishi mumkin — u holda bo'laklarni requestAnimationFrame bilan guruhlab yangilash mumkin (rAF). Kanonda javoblar qisqa — o'lchanmagan.
  • Kech chunk. react-markdown 46.1 KB — IzohKorinishi kech chunk'ida (Markdown). Yordamchi uni Suspense bilan oladi. Chunk kelguncha fallback — xom matn (<p className="izoh-xom">), shuning uchun birinchi so'zlar kutmaydi.
  • Xavfsizlik. AI javobi — foydalanuvchi kiritgan matn kabi ishonchsiz. react-markdown skipHtml bilan: <img onerror> ham, javascript: havola ham ishlamaydi. Kanon e2e #08 da shuni tekshirgan.

6. Vercel AI SDK: useChat

Hammasini o'zingiz yozish shart emas. Eng mashhur tayyor yechim — Vercel AI SDK. Brauzer qismi @ai-sdk/react dagi useChat hook'i: xabarlar ro'yxati, oqimni o'qish, to'xtatish, qayta yaratish — bitta hook'da. Hujjat bo'yicha ko'rinishi (bizda ishga tushirilmagan — server kerak):

tsx
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";

export function Yordamchi() {
  const { messages, sendMessage, status, stop, regenerate } =
    useChat({
      transport: new DefaultChatTransport({ api: "/api/chat" }),
    });
  return (
    <>
      {messages.map((m) => (
        <div key={m.id}>
          {m.role === "user" ? "Siz: " : "AI: "}
          {m.parts.map((q, i) =>
            q.type === "text" ? <span key={i}>{q.text}</span> : null,
          )}
        </div>
      ))}
      <button onClick={() => sendMessage({ text: "Salom" })}>
        Yuborish
      </button>
      {status === "streaming" && (
        <button onClick={stop}>To'xtatish</button>
      )}
      <button onClick={() => regenerate()}>Qayta yaratish</button>
    </>
  );
}

status to'rt qiymat oladi: "submitted" (yuborildi, javob hali yo'q), "streaming" (oqim kelyapti), "ready", "error". Xabar matni parts massivida — chunki javobda matndan tashqari asbob chaqiruvi (tool call) yoki rasm ham bo'lishi mumkin. Server qismi (streamText) shu SDK'ning boshqa paketida — o'z backend'imiz bo'lganda (25-qism) ko'ramiz.

Kanon uni o'lchadi (@ai-sdk/react 4.0.140, eng kichik misol, kutubxonalar-21.txt):

text
ai-sdk                 JS  112911 (+  44.1 KB) CSS      0 | CSP require-trusted-types-for×1 | xato: This document requires 'TrustedScript' assignment. The action has been blocked. | ochiq: tashqi yo'q  ← qayta (peer bog'liqliklar o'rnatilgach)

Ikki fakt. Birinchisi — +44.1 KB gzip. Bizning yordamchimiz esa hech qanday kutubxona qo'shmaydi: fetch, TextDecoderStream va 40 qatorlik ajratgich brauzerda allaqachon bor narsalar ustida. Ikkinchisi — Trusted Types buzilishi: TrustedScript. Ya'ni to'plam ichida qayerdadir satrdan kod yaratish urinishi bor (Brauzer xavfsizlik modeli). vazifalar CSP'si buni to'sadi. Aniq qaysi qator ekanini kanon izlamagan — buni qayta tekshirish o'z loyihangizda kerak.

Qachon AI SDK to'g'ri tanlov? Next.js loyihasida, bir nechta provayder (OpenAI, Anthropic, Gemini) almashib turadigan, asbob chaqiruvlari bor murakkab agentda. Server va klient bir xil kutubxonadan — protokolni o'ylamaysiz. Bitta oddiy matnli chat uchun esa 44 KB va Trusted Types bilan kurash — ortiqcha.

7. Hujumchi nigohi

API kalit brauzerda. Eng ko'p uchraydigan xato: VITE_OPENAI_KEY ni .env ga yozib, fetch ni to'g'ridan-to'g'ri AI provayderiga yuborish. Vite env darsini eslang: VITE_ bilan boshlangan hamma narsa build'da JS faylga matn bo'lib yoziladi. Har kim DevTools → Sources'da kalitni topadi va sizning hisobingizdan minglab so'rov yuboradi — to'lovni siz qilasiz.

To'g'ri yo'l:

sequenceDiagram
  participant B as Brauzer
  participant S as O'z serveringiz
  participant M as AI provayder
  B->>S: POST /api/chat (tarix)
  Note over S: kirganmi? limit? matn uzunligi?
  S->>M: so'rov + API kalit
  M-->>S: oqim (SSE)
  S-->>B: oqim (o'z formatimiz)

Diagrammaga qarang: kalit faqat server bilan provayder orasida. Server qo'shimcha uch ishni qiladi: kim so'rayotganini tekshiradi, kunlik limit qo'yadi, juda uzun tarixni qisqartiradi. Kanon izohi: "API kalit HECH QACHON brauzerda emas — server (25/31-qism) modelga so'raydi". vazifalar da VITE_AI_URL faqat manzil, kalit emas — va u faqat .env.development da.

Prompt injection. Hujumchi savolga "Oldingi ko'rsatmalarni unut va …" yozadi. Bu frontend muammosi emas — modelni server boshqaradi. Frontendning vazifasi boshqa: javobni ishonchsiz matn sifatida chizish. Model hujumchining iltimosi bilan <img src=x onerror=…> yoki [bos](javascript:…) yozsa ham, skipHtml va havola tekshiruvi uni zararsiz qiladi.

Shaxsiy ma'lumot. Chat tarixi serverga, u yerdan provayderga ketadi. Foydalanuvchi telefon raqami yoki parolini yozishi mumkin. Rozilik va maxfiylik — Analitika darsidagi qoidalar bu yerda ham: nimani kimga yuborayotganingizni foydalanuvchiga ayting.

8. Vazifalar qadami: AI yordamchi (holat 26)

bash
git switch -c feat/yordamchi

8.1 Soxta oqim — faqat dev serverda

Haqiqiy AI uchun server va kalit kerak — ular 25-qismda. Hozir dev server'ga Vite plagini qo'shiladi (o'z plaginingiz): POST /__ai/chat ga SSE oqim qaytaradi. skriptlar/ai-soxta.js ning boshi (kanon):

js
// skriptlar/ai-soxta.js — SOXTA AI javobi, faqat dev
// server'da (21/#26). POST /__ai/chat { xabarlar: [{ rol, matn }] }
// → Server-Sent Events oqimi: "data: {"matn":"bo'lak"}" har 30 ms
// da, oxirida "event: tugadi". Haqiqiy LLM yo'q (API kalit — hech
// qachon brauzerda emas: 25/31-qismda o'z backend'imiz modelga
// so'raydi, brauzer esa shu shakldagi oqimni oladi). Xabarda "xato"
// so'zi bo'lsa — oqim o'rtasida uziladi (xato holatini sinash)
const MANZIL = "/__ai/chat";
const BOLAK_MS = 30;

Plagin apply: "serve" — faqat npm run dev da ishlaydi, build'ga kirmaydi. Ichi «Haqiqiy oqim» bo'limidagi serverga o'xshaydi: res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store" }), har 30 ms da res.write, oxirida event: tugadi. "xato" so'zi bo'lsa 6 ta bo'lakdan keyin res.destroy() — brauzer uzilishni ko'radi. To'xtatish — res.on("close"), req emas.

.env.development ga ikki qator (kanon):

bash
# 21/#26: AI yordamchi — dev serverdagi SOXTA oqim
# (skriptlar/ai-soxta.js); flag'i ham shu yerda yoqilgan.
# Production'da yo'q (kalit — faqat serverda)
VITE_AI_URL=/__ai/chat
VITE_FLAGLAR=ai-yordamchi

8.2 Sahifa, marshrut va flag

Fayllar — src/xususiyatlar/yordamchi/: oqim.ts («SSE ajratgich» bo'limi), YordamchiSahifasi.tsx (shu darsdagi parchalar) va index.ts. Marshrut kech yuklanadi (lazy) — marshrutlar.tsx ga (kanon):

tsx
// 21/#26: AI yordamchi — kech yuklanadi (router lazy): sahifa kodi
// faqat shu manzil ochilganda
{
  path: "yordamchi",
  lazy: () =>
    import("./xususiyatlar/yordamchi/index.ts").then((m) => ({
      Component: m.YordamchiSahifasi,
    })),
  handle: nomi("Yordamchi"),
},

Menyudagi havola ikki shart bilan chiqadi — flag (Feature flag) va build'da manzil bor:

tsx
// 21/#26: yordamchi — flag yoqilgan VA build'da manzil bor bo'lsa
const yordamchi =
  useFlag("ai-yordamchi") &&
  import.meta.env.VITE_AI_URL !== undefined;

flag-royxati.ts ga "ai-yordamchi": false, vite-env.d.ts ga readonly VITE_AI_URL?: string;. Production'da manzil yo'q — kimdir ?flag=ai-yordamchi bilan kirsa ham menyuda yo'q, /yordamchi esa «Yordamchi bu build'da ulanmagan (server kerak — 25-qism).» deydi.

8.3 Natija

Tekshiruvlar (haqiqiy, holat 26):

  • npm test — node:test 245 (avval 241, +4 oqim.test.js), Vitest 65; lint, format:check, tip, knip — exit 0.
  • Build: yordamchi chunk'i 0.6 KB gzip (yordamchi-*.js, 586 bayt). Bu production build: unda VITE_AI_URL yo'q, Vite import.meta.env.VITE_AI_URL ni undefined bilan almashtiradi va sahifa «ulanmagan» shoxiga qisqaradi. Chatli (manzilli) build hajmi kanonda alohida o'lchanmagan. Birinchi yuklash 223.9 → 224.1 KB (+0.2 KB).
  • e2e-21 (production) — yordamchi menyuda yo'q, kodi birinchi yuklashda yo'q, /yordamchi — «ulanmagan» xabari; jami 67.
  • dev-21 — dev server ustida, AI chat qismi (natija-dev-26.txt):
text
✅ menyuda «Yordamchi» (dev: flag va VITE_AI_URL): "Yordamchi"
✅ yozmoqda: To'xtatish bor, Yuborish faol emas, aria-busy: [true,"true"]
✅ oqim: matn kamida 10 bosqichda o'sdi (bir martada emas): true
✅ tayyor: Markdown (qalin, 3 qadam, kod), holat, Qayta yaratish: [1,3,2,"tayyor",true]
✅ to'xtatildi: e'lon, holat, matn boshqa o'smaydi: ["Javob to'xtatildi.","toxtatildi",true]
✅ qayta yaratildi: 4 xabar, oxirgisi tayyor: [4,"tayyor"]
✅ uzilish: holat xato, e'lon: true
✅ AI chat: CSP buzilishi yo'q: []

Uchinchi qatorni o'qing: "matn kamida 10 bosqichda o'sdi" — oqim haqiqatan bo'lakma-bo'lak chizilgani o'lchandi, bir martada emas. Dev-21 jami 15 (analitika 5, xatolar 2, AI chat 8).

bash
git add -A
git commit

Commit (kanon): feat: AI yordamchi — oqimli javob (fetch + SSE), to'xtatish, qayta yaratish; dev'da soxta.

9. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
javob bir martada, oxirida chiqadi await response.json()/text() response.body.getReader()
har safar faqat oxirgi so'z setJavob(javob + soz) — eski qiymat setJavob((e) => e + soz)
oqim boshlanishi bilan uziladi (server) req.on("close") res.on("close")
JSON.parse xatosi tasodifiy bo'lak = hodisa deb o'ylangan qoldiq va \n\n bo'yicha ajratish
"яЁ" kabi buzuq harflar baytlar bo'lakma-bo'lak decode TextDecoderStream
API kalit DevTools'da ko'rinadi VITE_…_KEY so'rov — o'z serveringiz orqali
ekran o'quvchi tinmay gapiradi javob aria-live ichida aria-busy + oxirida role="status"

10. Mashqlar

Mashqlar kurs/mashqlar/21/26-ai-chat/ da.

1-mashq (oson): bo'shliqlarni to'ldiring

  • SSE hodisalari bir-biridan bilan ajraladi.
  • Chat EventSource o'rniga fetch ishlatadi, chunki EventSource faqat yuboradi.
  • «To'xtatish» tugmasi AbortController ning metodini chaqiradi.
  • Node serverida brauzer uzganini bildiradigan hodisa — res ning hodisasi.
  • Vercel AI SDK'dagi chat hook'i — .
Yechim
  • bo'sh qator (\n\n) — hodisa chegarasi, bitta \n emas.
  • GET — tana va o'z sarlavhalaringiz yo'q; chat esa tarixni POST bilan yuboradi.
  • abort — signal bekor bo'ladi, fetch va read() AbortError bilan tugaydi.
  • close — req niki emas: u tana o'qilgach darhol keladi.
  • useChat — @ai-sdk/react dan.

2-mashq (o'rta): matndan hodisalar

Funksiya hodisalar(matn) yozing: to'liq SSE matnini oladi va [tur, malumot] juftliklari massivini qaytaradi. Qoidalar «SSE formati» bo'limidagidek: bo'sh qator — chegara, event: — tur (bo'lmasa "message"), bir nechta data: — \n bilan qo'shiladi, : bilan boshlangan qator tashlanadi. Tekshiring:

js
const matn =
  'data: {"matn":"Osh"}\n\n: ping\n\nevent: tugadi\ndata: {}\n\n';
console.log(hodisalar(matn));
// [ [ 'message', '{"matn":"Osh"}' ], [ 'tugadi', '{}' ] ]

Ishora: matn.split("\n\n") — bloklar; har blok split("\n") — qatorlar (satr metodlari). Bo'sh blok va faqat izohdan iborat blokni tashlang.

Yechim
js
function hodisalar(matn) {
  const natija = [];
  for (const blok of matn.split("\n\n")) {
    let tur = "message";
    const malumot = [];
    for (const qator of blok.split("\n")) {
      if (qator.startsWith("event:")) tur = qator.slice(6).trim();
      if (qator.startsWith("data:")) {
        malumot.push(qator.slice(5).replace(/^ /, ""));
      }
    }
    if (malumot.length > 0) natija.push([tur, malumot.join("\n")]);
  }
  return natija;
}

const matn =
  'data: {"matn":"Osh"}\n\n: ping\n\nevent: tugadi\ndata: {}\n\n';
console.log(hodisalar(matn));
console.log(hodisalar("data: a\ndata: b\n\n"));

Konsolda:

text
[ [ 'message', '{"matn":"Osh"}' ], [ 'tugadi', '{}' ] ]
[ [ 'message', 'a\nb' ] ]

Bu — butun matn qo'lda bo'lgandagi versiya. Oqimda esa matn bo'lakma-bo'lak keladi, shuning uchun kanon qoldiq saqlaydi. split("\n\n") dan oxirgi bo'sh blok — malumot bo'sh, tashlanadi.

3-mashq (qiyin): «Qayta yaratish» va hisoblagich

«Jonli model» dagi chatni kengaytiring:

  1. Oqim tugagach (yoki to'xtatilgach) «Qayta yaratish» tugmasi chiqsin. U o'sha savolni qayta yuboradi.
  2. Har yuborishda konsolga so'rov N: <savol> yozilsin (N — nechanchi so'rov).
  3. Javob to'xtatilgan bo'lsa, ostida (to'xtatildi) yozuvi.

Ishora: savolni useRef da saqlang — «Qayta yaratish» forma maydoni o'zgargan bo'lsa ham oxirgi yuborilgan savolni oladi. So'rovlar soni ham useRef.

Yechim
jsx
import { useRef, useState } from "react";

const JAVOB = "Xamir, go'sht va piyoz kerak.";

async function* soxtaOqim(signal) {
  for (const soz of JAVOB.split(/(?<=\s)/)) {
    await new Promise((r) => setTimeout(r, 300));
    if (signal.aborted) return;
    yield soz;
  }
}

export default function App() {
  const [savol, setSavol] = useState("");
  const [javob, setJavob] = useState("");
  const [holat, setHolat] = useState("bo'sh");
  const pult = useRef(null);
  const oxirgiSavol = useRef("");
  const soni = useRef(0);

  async function sora(matn) {
    soni.current++;
    console.log(`so'rov ${soni.current}: ${matn}`);
    oxirgiSavol.current = matn;
    const p = new AbortController();
    pult.current = p;
    setJavob("");
    setHolat("yozmoqda");
    for await (const soz of soxtaOqim(p.signal)) {
      setJavob((eski) => eski + soz);
    }
    setHolat(p.signal.aborted ? "to'xtatildi" : "tayyor");
  }

  const yozmoqda = holat === "yozmoqda";
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        void sora(savol);
      }}
    >
      <label htmlFor="savol">Savolingiz</label>{" "}
      <input
        id="savol"
        value={savol}
        onChange={(e) => setSavol(e.target.value)}
      />{" "}
      <button disabled={yozmoqda}>Yuborish</button>{" "}
      {yozmoqda ? (
        <button type="button" onClick={() => pult.current.abort()}>
          To'xtatish
        </button>
      ) : (
        holat !== "bo'sh" && (
          <button
            type="button"
            onClick={() => void sora(oxirgiSavol.current)}
          >
            Qayta yaratish
          </button>
        )
      )}
      <p>{javob}</p>
      {holat === "to'xtatildi" && <p>(to'xtatildi)</p>}
    </form>
  );
}

Konsolda:

text
so'rov 1: Manti
so'rov 2: Manti

Ekranda (qayta yaratilgan javob tugagach):

text
Savolingiz Yuborish Qayta yaratish
Xamir, go'sht va piyoz kerak.

Tekshiruv qadamlari: birinchi javob birinchi so'zdan oldin to'xtatildi — ekranda faqat «(to'xtatildi)» (oraliq nuqta). Keyin «Qayta yaratish» o'sha savolni ikkinchi marta yubordi va javob to'liq keldi. So'z orasidagi 300 ms ataylab katta: to'xtatish birinchi so'zdan oldin ulgursin. Soxta oqim signalni faqat kutishdan keyin tekshiradi, shuning uchun holat ~300 ms da o'zgaradi; haqiqiy fetch esa abort() da darhol to'xtaydi. Real chatda to'xtatilgan javob yarim qoladi — kanon uni o'chirmaydi.

4-mashq: Vazifalar qadami

feat/yordamchi branch'ida «Vazifalar qadami» bo'limidagi o'zgarishlarni qiling: oqim.ts va uning testi, YordamchiSahifasi.tsx, skriptlar/ai-soxta.js plagini, .env.development, marshrut va flag.

Yechim

Tekshiruv ro'yxati:

  • npm test — node 245, Vitest 65; npm run tip — 0 xato.
  • npm run dev → menyuda «Yordamchi». Savol yozing — javob so'zma-so'z chiqadi, «To'xtatish» bor. "xato" so'zi bilan yozing — «(javob uzildi)».
  • DevTools → Network → chat so'rovi → EventStream tabi: hodisalar alohida qatorlarda.
  • npm run build → yordamchi-*.js ~0.6 KB (bu VITE_AI_URL siz build — chat kodi tashlab yuborilgan; chatli build o'lchanmagan); production'da menyuda «Yordamchi» yo'q.
  • skriptlar/ai-soxta.js da res.on("close") — req ga almashtirib ko'ring: oqim darhol to'xtaydi. Qaytaring.

11. Real ishda

  • AI yordamchilar hamma joyda: qo'llab-quvvatlash chatlari, hujjat bo'yicha savol-javob, kod yordamchilari. Frontend dasturchidan aynan shu dars ko'nikmalari so'raladi: oqim, to'xtatish, qayta yaratish, Markdown.
  • Kod ko'rigida birinchi savol: "kalit qayerda?" Brauzer bundle'ida sk-… kabi satr — darhol to'xtatiladigan xato.
  • Intervyu savollari: "SSE va WebSocket farqi?", "nega EventSource emas?", "oqimni qanday to'xtatasiz?", "stale closure oqimda qanday chiqadi?".

Xulosa

  • AI javobi bo'lakma-bo'lak keladi: fetch → response.body → TextDecoderStream → o'quvchi.
  • SSE: data:, event:, bo'sh qator. Bo'lak ≠ hodisa — qoldiqda yig'ib, \n\n bo'yicha ajrating.
  • React: setJavob((e) => e + soz), AbortController useRef da, oqim paytida «Yuborish» o'chiq; qayta yaratish — butun tarix bilan.
  • Avto-aylantirish faqat foydalanuvchi pastda bo'lsa; ekran o'quvchiga — aria-busy va oxirida role="status".
  • Vercel AI SDK useChat — qulay, lekin +44.1 KB va Trusted Types buzilishi; kanon o'zinikini yozdi (kutubxonasiz).
  • API kalit — faqat serverda. Node'da to'xtatish — res.on("close").

Keyingi dars: Uchinchi tomon skriptlar va embedlar — chat vidjetlari, YouTube va pikselni sahifani sekinlashtirmasdan va CSP'ni buzmasdan ulash.

Manbalar

  • MDN: Streams API, TextDecoderStream, Server-sent events (Using server-sent events — format)
  • WHATWG HTML: Server-sent events — "Interpreting an event stream"
  • Node.js hujjati: http.IncomingMessage va http.ServerResponse — 'close' hodisasi
  • AI SDK: ai-sdk.dev — useChat, DefaultChatTransport, regenerate, stop
  • Kanon: vazifalar holat 26, tekshiruv-26.txt, natija-dev-26.txt, natija-21-26.txt, tajriba/kutubxonalar-21.txt (2026-10-17)
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
AI chat interfeysi React'da: oqimli javob, SSE, to'xtatish va qayta yaratish — IlmHamroh