Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"preview": "vite preview --port 5173"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"axios": "^1.9.0",
"i18nexus": "^2.5.1",
"pdfjs-dist": "^4.8.69",
Expand Down
174 changes: 71 additions & 103 deletions src/features/quiz-generation/model/useQuizGenerationStore.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { fetchEventSource } from "@microsoft/fetch-event-source";
import axiosInstance from "#shared/api";
import { getAccessToken } from "#entities/auth";

Expand Down Expand Up @@ -58,133 +59,100 @@ export const useQuizGenerationStore = create((set, get) => ({
error: null,
});

const accessToken = getAccessToken();
let firstChunkHandled = false;

try {
const accessToken = getAccessToken();
const response = await fetch(buildApiUrl("/generation"), {
await fetchEventSource(buildApiUrl("/generation"), {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: "include",
body: JSON.stringify(requestData),
signal: controller.signal,
});

if (!response.ok) {
throw new Error("문제 생성 요청에 실패했습니다.");
}
openWhenHidden: true,

if (!response.body) {
throw new Error("스트리밍 응답을 읽을 수 없습니다.");
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let firstChunkHandled = false;

const handleChunk = (data) => {
const nextProblemSetId = data?.problemSetId;
const nextTotalCount = Number(
data?.totalCount ?? data?.problemCount ?? data?.quizCount ?? 0,
);

if (!firstChunkHandled && nextProblemSetId) {
firstChunkHandled = true;
set({ problemSetId: nextProblemSetId });
if (typeof onFirstChunk === "function") {
onFirstChunk({
problemSetId: nextProblemSetId,
totalCount: nextTotalCount,
});
async onopen(response) {
if (response.ok) {
return;
}
}

if (nextTotalCount > 0 && get().totalCount === 0) {
set({ totalCount: nextTotalCount });
}
throw new Error("문제 생성 요청에 실패했습니다.");
},

const quizzesFromChunk = Array.isArray(data?.quiz) ? data.quiz : [];
const hasSingleQuizPayload =
data &&
(data.title || data.content || data.question || data.selections);

if (quizzesFromChunk.length > 0) {
set((state) => ({
quizzes: [...state.quizzes, ...quizzesFromChunk],
}));
} else if (hasSingleQuizPayload) {
set((state) => ({ quizzes: [...state.quizzes, data] }));
}
};

const parseEvent = (rawEvent) => {
const lines = rawEvent.split("\n");
const dataLines = [];
let eventType = null;

for (const line of lines) {
if (!line.trim()) continue;
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
onmessage(msg) {
if (msg.event === "error") {
throw new Error(msg.data);
}
}

const payload = dataLines.join("\n").trim();
if (!payload || payload === "[DONE]") return;

if (eventType === "error") {
set({ isLoading: false, error: payload });
if (typeof onError === "function") {
onError(new Error(payload));
if (msg.data === "[DONE]" || !msg.data) {
return;
}
return;
}

try {
const data = JSON.parse(payload);
handleChunk(data);
} catch (error) {
console.error("SSE 데이터 파싱 실패:", error);
}
};

while (true) {
const { done, value } = await reader.read();
if (done) break;
try {
const data = JSON.parse(msg.data);

const nextProblemSetId = data?.problemSetId;
const nextTotalCount = Number(
data?.totalCount ?? data?.problemCount ?? data?.quizCount ?? 0,
);

if (!firstChunkHandled && nextProblemSetId) {
firstChunkHandled = true;
set({ problemSetId: nextProblemSetId });
if (typeof onFirstChunk === "function") {
onFirstChunk({
problemSetId: nextProblemSetId,
totalCount: nextTotalCount,
});
}
}

if (nextTotalCount > 0 && get().totalCount === 0) {
set({ totalCount: nextTotalCount });
}

const quizzesFromChunk = Array.isArray(data?.quiz) ? data.quiz : [];
const hasSingleQuizPayload =
data &&
(data.title || data.content || data.question || data.selections);

if (quizzesFromChunk.length > 0) {
set((state) => ({
quizzes: [...state.quizzes, ...quizzesFromChunk],
}));
} else if (hasSingleQuizPayload) {
set((state) => ({ quizzes: [...state.quizzes, data] }));
}
} catch (error) {
console.error("SSE 데이터 파싱 실패:", error);
}
},

buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
onclose() {
throw new Error("STREAM_COMPLETE");
},

while (buffer.includes("\n\n")) {
const boundaryIndex = buffer.indexOf("\n\n");
const rawEvent = buffer.slice(0, boundaryIndex);
buffer = buffer.slice(boundaryIndex + 2);
if (rawEvent.trim()) {
parseEvent(rawEvent);
onerror(err) {
throw err;
},
});
} catch (error) {
if (error.message === "STREAM_COMPLETE") {
if (activeController === controller) {
set({ isLoading: false });
if (typeof onComplete === "function") {
onComplete(get().problemSetId);
}
}
return;
}

if (buffer.trim()) {
buffer = buffer.replace(/\r\n/g, "\n");
parseEvent(buffer);
if (error.name === "AbortError") {
return;
}

if (activeController === controller) {
set({ isLoading: false });
if (typeof onComplete === "function") {
onComplete(get().problemSetId);
}
}
} catch (error) {
if (error?.name !== "AbortError" && activeController === controller) {
set({ isLoading: false, error: error?.message || "알 수 없는 오류" });
if (typeof onError === "function") {
onError(error);
Expand Down
2 changes: 2 additions & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import react from "@vitejs/plugin-react";
import process from "process";
import { createRequire } from "module";
import { defineConfig, loadEnv } from "vite";
import path from "path";

const require = createRequire(import.meta.url);
const vitePrerender = require("vite-plugin-prerender");
Expand All @@ -12,6 +13,7 @@ export default defineConfig(({ mode, command }) => {
const isBuild = command === "build";
const prerenderPlugin = isBuild
? vitePrerender({
staticDir: path.resolve("dist"),
routes: ["/", "/ko", "/en"],
})
: null;
Expand Down