141 lines
4.3 KiB
TypeScript
141 lines
4.3 KiB
TypeScript
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
|
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
|
import { useAuthStore } from "@/store/auth-store"
|
|
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
|
import type { WritingFeedback } from "@/types"
|
|
|
|
const AUTH_DAILY_LIMIT = 10
|
|
const GUEST_DAILY_LIMIT = 3
|
|
|
|
// Resolve env at runtime — production injects window.__ENV__ via docker/entrypoint.sh,
|
|
// dev reads from Vite's import.meta.env. Must match src/lib/supabase.ts.
|
|
function resolveSupabaseEnv() {
|
|
const runtime = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
|
|
const url = runtime.VITE_SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL
|
|
const key =
|
|
runtime.VITE_SUPABASE_ANON_KEY ||
|
|
runtime.VITE_SUPABASE_PUBLISHABLE_KEY ||
|
|
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
|
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
|
return { url: url as string | undefined, key: key as string | undefined }
|
|
}
|
|
|
|
// Calls the writing-check-dbiz Supabase edge function.
|
|
// SSE format emitted by the function: data: {"text":"..."} | data: [DONE]
|
|
async function callEdgeFunction(
|
|
content: string,
|
|
onChunk?: (text: string) => void,
|
|
): Promise<WritingFeedback> {
|
|
const { url, key } = resolveSupabaseEnv()
|
|
if (!url || !key) {
|
|
throw new Error("Supabase chưa được cấu hình. Vui lòng kiểm tra biến môi trường.")
|
|
}
|
|
const res = await fetch(`${url}/functions/v1/writing-check-dbiz`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${key}`,
|
|
apikey: key,
|
|
},
|
|
body: JSON.stringify({ content }),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}))
|
|
throw new Error(body?.error ?? "Đã có lỗi khi chấm bài. Vui lòng thử lại.")
|
|
}
|
|
|
|
const reader = res.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ""
|
|
let accumulated = ""
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split("\n")
|
|
buffer = lines.pop() ?? ""
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue
|
|
const payload = line.slice(6).trim()
|
|
if (payload === "[DONE]") continue
|
|
|
|
let chunk: { text?: string; error?: string }
|
|
try {
|
|
chunk = JSON.parse(payload)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
if (chunk.error) throw new Error(chunk.error)
|
|
|
|
const text = chunk.text ?? ""
|
|
if (text) {
|
|
accumulated += text
|
|
onChunk?.(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
const start = accumulated.indexOf("{")
|
|
const end = accumulated.lastIndexOf("}")
|
|
if (start === -1 || end === -1) {
|
|
throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
|
}
|
|
|
|
const raw = JSON.parse(accumulated.slice(start, end + 1))
|
|
|
|
const toArray = (v: unknown): string[] => {
|
|
if (Array.isArray(v)) return v
|
|
if (typeof v === "string" && v.length > 0) return [v]
|
|
return []
|
|
}
|
|
|
|
return {
|
|
...raw,
|
|
grammar: toArray(raw.grammar),
|
|
vocabulary: toArray(raw.vocabulary),
|
|
improvedVersion: raw.improved_version ?? raw.improvedVersion ?? "",
|
|
} as WritingFeedback
|
|
}
|
|
|
|
export function useWritingCheck() {
|
|
const queryClient = useQueryClient()
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
content,
|
|
onChunk,
|
|
}: {
|
|
content: string
|
|
onChunk?: (text: string) => void
|
|
}): Promise<WritingFeedback> => {
|
|
const user = useAuthStore.getState().user
|
|
|
|
if (user) {
|
|
const usedToday = await countTodayWritingSubmissions(user.id)
|
|
if (usedToday >= AUTH_DAILY_LIMIT) {
|
|
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
|
|
}
|
|
} else {
|
|
if (!canUseWritingCheck()) {
|
|
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
|
|
}
|
|
}
|
|
|
|
const feedback = await callEdgeFunction(content, onChunk)
|
|
|
|
if (user) {
|
|
await saveWritingSubmission(user.id, content, feedback)
|
|
queryClient.invalidateQueries({ queryKey: ["writing-history"] })
|
|
} else {
|
|
recordWritingCheckUsage()
|
|
}
|
|
|
|
return feedback
|
|
},
|
|
})
|
|
}
|