Mundarija (33)
- Bu darsda
- 1. Nega bu kerak?
- 2. Oqim: javob bo'laklarda keladi
- 2.1 response.body — eslab olamiz
- 2.2 SSE formati — uch qoida
- 2.3 Nega EventSource emas?
- 2.4 Haqiqiy oqim: server va brauzer bitta faylda
- 3. SSE ajratgich: bo'laklardan hodisalar
- 3.1 Bo'lak ≠ hodisa
- 3.2 Hodisadan matnga
- 4. React'da chat: to'xtatish va qayta yaratish
- 4.1 Jonli model
- 4.2 «To'xtatish» qanday ishlaydi
- 4.3 «Qayta yaratish»
- 5. Chat UI naqshlari
- 5.1 Avto-aylantirish — lekin majburlamay
- 5.2 Ekran o'quvchi: har so'z emas
- 5.3 Oqimdagi Markdown
- 6. Vercel AI SDK: useChat
- 7. Hujumchi nigohi
- 8. Vazifalar qadami: AI yordamchi (holat 26)
- 8.1 Soxta oqim — faqat dev serverda
- 8.2 Sahifa, marshrut va flag
- 8.3 Natija
- 9. Ko'p uchraydigan xatolar
- 10. Mashqlar
- 1-mashq (oson): bo'shliqlarni to'ldiring
- 2-mashq (o'rta): matndan hodisalar
- 3-mashq (qiyin): «Qayta yaratish» va hisoblagich
- 4-mashq: Vazifalar qadami
- 11. Real ishda
- Xulosa
- Manbalar
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
fetchningresponse.bodyoqimini o'qiydi, server esa matnni SSE (Server-Sent Events) shaklidadata: …qatorlari bilan yuboradi. «To'xtatish» —AbortController, «Qayta yaratish» — oxirgi savolni qayta yuborish. API kalit hech qachon brauzerga tushmaydi: modelga faqat server so'raydi.vazifalarda yordamchi dev serverdagi soxta oqim bilan ishlaydi; production'da u o'chiq va kodi kech chunk'da.
Bu darsda
fetchjavobini oqim bilan o'qib, matnni kelishi bilan chizasiz.- SSE formatini (
data:,event:, bo'sh qator) o'zingiz ajratasiz va negaEventSourceemas,fetchekanini tushuntirasiz. - «To'xtatish», «Qayta yaratish», avto-aylantirish va oqimdagi Markdown'ni React'da yozasiz.
- Vercel AI SDK
useChatnima 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:
- Har hodisa — bir nechta qator, oxirida bo'sh qator (
\n\n). data: …— hodisaning ma'lumoti. Bir nechtadata:qatori bo'lsa — ular\nbilan qo'shiladi.event: nom— hodisa turi. Bo'lmasa —message.:bilan boshlangan qator — izoh (masalan, aloqa uzilmasin deb yuboriladigan "ping").
«Bahor» yordamchisining javobi tarmoqda shunday ko'rinadi:
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:
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/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 taUch narsaga qarang:
- Beshta
read()— beshta bo'lak, har biri o'z vaqtida keldi.JSON.stringify(value)\nlarni ko'rinadigan qildi. TextDecoderStream— baytlarni matnga aylantiradi. Oddiynew TextDecoder().decode(bolak)dan farqi: o'zbekcha yoki emoji harfi ikki bo'lakka bo'linib kelsa ham buzmaydi.- Jurnal.
reqningclosehodisasi birorta so'z yuborilmasdan keldi (0 ta).resniki 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:
// 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 baribirfetchbilan 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:
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:
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:
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:
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):
Savolingiz Yuborish
Osh uchun:
Zirvak qiling
Guruch soling
Damlang
Holat: tayyorKonsolda:
bo'laklar: 11Qadamma-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 + sozdeb yozilsa, sikl birinchi renderdagi bo'shjavobni yopib oladi (eskirgan closure) — har safar faqat oxirgi so'z qolardi.useRefdagiAbortController— «To'xtatish» bosilgandaabort(). 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:
} 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:
// 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:
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.
// 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:
import Markdown from "react-markdown";
export default function App() {
return (
<>
<Markdown>{"**Osh tay"}</Markdown>
<Markdown>{"**Osh tayyor**"}</Markdown>
</>
);
}Ekranda:
**Osh tay
Osh tayyorYopilmagan ** 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
requestAnimationFramebilan guruhlab yangilash mumkin (rAF). Kanonda javoblar qisqa — o'lchanmagan. - Kech chunk. react-markdown 46.1 KB —
IzohKorinishikech chunk'ida (Markdown). Yordamchi uniSuspensebilan oladi. Chunk kelgunchafallback— xom matn (<p className="izoh-xom">), shuning uchun birinchi so'zlar kutmaydi. - Xavfsizlik. AI javobi — foydalanuvchi kiritgan matn kabi ishonchsiz. react-markdown
skipHtmlbilan:<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):
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):
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)
git switch -c feat/yordamchi8.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):
// 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):
# 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-yordamchi8.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):
// 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:
// 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, +4oqim.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: undaVITE_AI_URLyo'q, Viteimport.meta.env.VITE_AI_URLniundefinedbilan 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):
✅ 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).
git add -A
git commitCommit (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
EventSourceo'rnigafetchishlatadi, chunkiEventSourcefaqat yuboradi. - «To'xtatish» tugmasi
AbortControllerning metodini chaqiradi. - Node serverida brauzer uzganini bildiradigan hodisa —
resning hodisasi. - Vercel AI SDK'dagi chat hook'i — .
Yechim
- bo'sh qator (
\n\n) — hodisa chegarasi, bitta\nemas. - GET — tana va o'z sarlavhalaringiz yo'q; chat esa tarixni POST bilan yuboradi.
- abort — signal bekor bo'ladi,
fetchvaread()AbortErrorbilan tugaydi. - close —
reqniki emas: u tana o'qilgach darhol keladi. - useChat —
@ai-sdk/reactdan.
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:
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
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:
[ [ '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:
- Oqim tugagach (yoki to'xtatilgach) «Qayta yaratish» tugmasi chiqsin. U o'sha savolni qayta yuboradi.
- Har yuborishda konsolga
so'rov N: <savol>yozilsin (N — nechanchi so'rov). - 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
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:
so'rov 1: Manti
so'rov 2: MantiEkranda (qayta yaratilgan javob tugagach):
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 →
chatso'rovi → EventStream tabi: hodisalar alohida qatorlarda. npm run build→yordamchi-*.js~0.6 KB (buVITE_AI_URLsiz build — chat kodi tashlab yuborilgan; chatli build o'lchanmagan); production'da menyuda «Yordamchi» yo'q.skriptlar/ai-soxta.jsdares.on("close")—reqga 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
EventSourceemas?", "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\nbo'yicha ajrating. - React:
setJavob((e) => e + soz),AbortControlleruseRefda, oqim paytida «Yuborish» o'chiq; qayta yaratish — butun tarix bilan. - Avto-aylantirish faqat foydalanuvchi pastda bo'lsa; ekran o'quvchiga —
aria-busyva oxiridarole="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.IncomingMessagevahttp.ServerResponse—'close'hodisasi - AI SDK: ai-sdk.dev —
useChat,DefaultChatTransport,regenerate,stop - Kanon:
vazifalarholat 26,tekshiruv-26.txt,natija-dev-26.txt,natija-21-26.txt,tajriba/kutubxonalar-21.txt(2026-10-17)
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!