diff --git a/src/features/writing/components/WritingChecker.tsx b/src/features/writing/components/WritingChecker.tsx
index 7028939..3abae4c 100644
--- a/src/features/writing/components/WritingChecker.tsx
+++ b/src/features/writing/components/WritingChecker.tsx
@@ -16,6 +16,7 @@ export function WritingChecker() {
const [text, setText] = useState('')
const [improvedExpanded, setImprovedExpanded] = useState(false)
const [remaining, setRemaining] = useState(getRemainingChecks)
+ const [streamingText, setStreamingText] = useState('')
const { mutate: checkWriting, isPending, isError, error, data: feedback, reset: resetMutation } = useWritingCheck()
const { requireAuth } = useRequireAuth()
@@ -42,19 +43,25 @@ export function WritingChecker() {
function handleSubmit() {
if (!requireAuth()) return
if (!canSubmit) return
- checkWriting(text, {
- onSuccess: () => {
- if (user) {
- awardActivity({ xp: XP_REWARDS.writing })
- countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
- } else {
- setRemaining(getRemainingChecks())
- }
+ setStreamingText('')
+ checkWriting(
+ { content: text, onChunk: (chunk) => setStreamingText((prev) => prev + chunk) },
+ {
+ onSuccess: () => {
+ setStreamingText('')
+ if (user) {
+ awardActivity({ xp: XP_REWARDS.writing })
+ countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
+ } else {
+ setRemaining(getRemainingChecks())
+ }
+ },
+ onError: () => {
+ setStreamingText('')
+ if (!user) setRemaining(getRemainingChecks())
+ },
},
- onError: () => {
- if (!user) setRemaining(getRemainingChecks())
- },
- })
+ )
}
return (
@@ -78,8 +85,9 @@ export function WritingChecker() {
value={text}
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
rows={12}
+ dir="ltr"
placeholder="Nhập bài writing của bạn vào đây... (TOEIC email, IELTS task, hoặc đoạn văn tự do)"
- className="w-full resize-none rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-blue-400 focus:bg-white transition-colors"
+ className="w-full resize-none rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-left text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-blue-400 focus:bg-white transition-colors"
/>
@@ -142,10 +150,21 @@ export function WritingChecker() {
)}
{isPending && (
-
-
-
AI đang phân tích bài viết...
-
Thường mất 3–5 giây
+
+
+
+
AI đang phân tích...
+
+ {streamingText ? (
+
+ {streamingText}
+ ▋
+
+ ) : (
+
+ )}
)}
diff --git a/src/features/writing/components/WritingHistory.tsx b/src/features/writing/components/WritingHistory.tsx
new file mode 100644
index 0000000..f917e76
--- /dev/null
+++ b/src/features/writing/components/WritingHistory.tsx
@@ -0,0 +1,134 @@
+import { useState } from 'react'
+import { useWritingHistory } from '@/hooks/use-writing-history'
+import { useAuthStore } from '@/store/auth-store'
+import type { WritingSubmission } from '@/types'
+
+function scoreColor(score: string) {
+ const n = parseFloat(score)
+ if (n >= 7) return 'bg-green-100 text-green-700'
+ if (n >= 5) return 'bg-amber-100 text-amber-700'
+ return 'bg-red-100 text-red-700'
+}
+
+function relativeTime(iso: string) {
+ const diff = Date.now() - new Date(iso).getTime()
+ const mins = Math.floor(diff / 60_000)
+ if (mins < 1) return 'vừa xong'
+ if (mins < 60) return `${mins} phút trước`
+ const hours = Math.floor(mins / 60)
+ if (hours < 24) return `${hours} giờ trước`
+ return `${Math.floor(hours / 24)} ngày trước`
+}
+
+function SubmissionCard({ item }: { item: WritingSubmission }) {
+ const [open, setOpen] = useState(false)
+ const fb = item.feedback
+
+ return (
+
+
+
+ {open && fb && (
+
+
+
Bài viết gốc
+
{item.content}
+
+
+ {fb.grammar?.length > 0 && (
+
+
+
+ Ngữ pháp
+
+
+ {fb.grammar.map((g, i) => (
+ -
+ •{g}
+
+ ))}
+
+
+ )}
+
+ {fb.vocabulary?.length > 0 && (
+
+
+
+ Từ vựng
+
+
+ {fb.vocabulary.map((v, i) => (
+ -
+ •{v}
+
+ ))}
+
+
+ )}
+
+ {fb.structure && (
+
+
Cấu trúc
+
{fb.structure}
+
+ )}
+
+ {fb.summary && (
+
+
Tổng nhận xét
+
{fb.summary}
+
+ )}
+
+ )}
+
+ )
+}
+
+export function WritingHistory() {
+ const user = useAuthStore((s) => s.user)
+ const { data: history, isLoading } = useWritingHistory()
+
+ if (!user) return null
+
+ return (
+
+ Lịch sử chấm bài
+
+ {isLoading && (
+
+ )}
+
+ {!isLoading && !history?.length && (
+
+
history
+
Chưa có bài nào được chấm.
+
+ )}
+
+ {!!history?.length && (
+
+ {history.map((item) => )}
+
+ )}
+
+ )
+}
diff --git a/src/hooks/use-writing-check.ts b/src/hooks/use-writing-check.ts
index a03b5c8..474c593 100644
--- a/src/hooks/use-writing-check.ts
+++ b/src/hooks/use-writing-check.ts
@@ -1,4 +1,4 @@
-import { useMutation } from "@tanstack/react-query"
+import { useMutation, useQueryClient } from "@tanstack/react-query"
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
import { useAuthStore } from "@/store/auth-store"
import { supabase } from "@/lib/supabase"
@@ -8,51 +8,119 @@ import type { WritingFeedback } from "@/types"
const AUTH_DAILY_LIMIT = 10
const GUEST_DAILY_LIMIT = 3
-async function callEdgeFunction(content: string): Promise
{
- const { data, error } = await supabase.functions.invoke("writing-check", {
- body: { content },
+const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string
+const SUPABASE_ANON_KEY = (
+ import.meta.env.VITE_SUPABASE_ANON_KEY || import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
+) as string
+
+// Calls the edge function and streams SSE chunks, invoking onChunk for each text piece.
+// Accumulates the full response, then extracts and parses the JSON object.
+async function callEdgeFunction(
+ content: string,
+ onChunk?: (text: string) => void,
+): Promise {
+ const { data: { session } } = await supabase.auth.getSession()
+
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/writing-check-dbiz`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ apikey: SUPABASE_ANON_KEY,
+ Authorization: `Bearer ${session?.access_token ?? SUPABASE_ANON_KEY}`,
+ },
+ body: JSON.stringify({ content }),
})
- if (error) {
- // The Supabase SDK wraps non-2xx responses in a generic FunctionsHttpError.
- // Try to parse the actual error body returned by the edge function.
- try {
- const body = await (error as unknown as { context: Response }).context.json()
- if (body?.error) throw new Error(body.error)
- } catch {
- // ignore parse failure, fall through to generic message
- }
- throw new Error("Đã có lỗi khi chấm bài. Vui lòng thử lại.")
+ 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.")
}
- if (!data) throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
+ const reader = res.body!.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ""
+ let accumulated = ""
- return data
+ 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 event: { text?: string; error?: string }
+ try {
+ event = JSON.parse(payload)
+ } catch {
+ continue // skip malformed SSE lines
+ }
+
+ if (event.error) throw new Error(event.error)
+ if (event.text) {
+ accumulated += event.text
+ onChunk?.(event.text)
+ }
+ }
+ }
+
+ // Extract the outermost JSON object from the accumulated stream
+ 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))
+
+ // Normalize: model sometimes returns array fields as a plain string
+ 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: string): Promise => {
+ mutationFn: async ({
+ content,
+ onChunk,
+ }: {
+ content: string
+ onChunk?: (text: string) => void
+ }): Promise => {
const user = useAuthStore.getState().user
if (user) {
- // Server-side rate limit for authenticated users (10/day)
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 {
- // localStorage rate limit for guests (3/day)
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)
+ const feedback = await callEdgeFunction(content, onChunk)
if (user) {
- // Save submission to DB (fire-and-forget)
saveWritingSubmission(user.id, content, feedback)
+ queryClient.invalidateQueries({ queryKey: ["writing-history"] })
} else {
recordWritingCheckUsage()
}
diff --git a/src/hooks/use-writing-history.ts b/src/hooks/use-writing-history.ts
new file mode 100644
index 0000000..2200e83
--- /dev/null
+++ b/src/hooks/use-writing-history.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query"
+import { useAuthStore } from "@/store/auth-store"
+import { fetchWritingHistory } from "@/lib/progress-service"
+import type { WritingSubmission } from "@/types"
+
+export function useWritingHistory() {
+ const user = useAuthStore((s) => s.user)
+ return useQuery({
+ queryKey: ["writing-history", user?.id],
+ queryFn: () => fetchWritingHistory(user!.id) as Promise,
+ enabled: !!user,
+ staleTime: 30_000,
+ })
+}
diff --git a/src/routes/writing.tsx b/src/routes/writing.tsx
index 2b8850a..d04a48c 100644
--- a/src/routes/writing.tsx
+++ b/src/routes/writing.tsx
@@ -1,6 +1,16 @@
import { createFileRoute } from "@tanstack/react-router"
import { WritingChecker } from "@/features/writing/components/WritingChecker"
+import { WritingHistory } from "@/features/writing/components/WritingHistory"
+
+function WritingPage() {
+ return (
+ <>
+
+
+ >
+ )
+}
export const Route = createFileRoute("/writing")({
- component: WritingChecker,
+ component: WritingPage,
})
diff --git a/src/types/index.ts b/src/types/index.ts
index 9f66455..a87e756 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -44,6 +44,13 @@ export interface WritingFeedback {
summary: string
}
+export interface WritingSubmission {
+ id: string
+ content: string
+ feedback: WritingFeedback
+ created_at: string
+}
+
export interface ToeicPart {
id: number
name: string
diff --git a/supabase/functions/writing-check-dbiz/index.ts b/supabase/functions/writing-check-dbiz/index.ts
new file mode 100644
index 0000000..1f57123
--- /dev/null
+++ b/supabase/functions/writing-check-dbiz/index.ts
@@ -0,0 +1,92 @@
+// Supabase Edge Function: writing-check-dbiz
+// Uses DBIZ LLM API (qwen-35b, OpenAI-compatible) to analyze English writing.
+// Deploy: supabase functions deploy writing-check-dbiz --no-verify-jwt
+// Secrets: supabase secrets set DBIZ_API_KEY=
+
+import OpenAI from "npm:openai@^4"
+
+const dbiz = new OpenAI({
+ apiKey: Deno.env.get("DBIZ_API_KEY") ?? "",
+ baseURL: "https://ai-api.dbiz.com/v1",
+})
+
+const CORS_HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
+}
+
+// Structure enforced via system prompt — qwen-35b via LiteLLM drops response_format params.
+const SYSTEM_PROMPT = `You are an expert English writing teacher specialising in TOEIC and IELTS assessment.
+Analyse the student's writing and respond ONLY with valid JSON — no markdown, no extra text:
+{
+ "score": "",
+ "grammar": [""],
+ "vocabulary": [""],
+ "structure": "<2–3 sentence structure assessment in Vietnamese>",
+ "improved_version": "",
+ "summary": "<2–3 sentence overall assessment in Vietnamese>"
+}
+All string values must use double quotes. Do not use single quotes or unquoted keys.`
+
+Deno.serve(async (req: Request) => {
+ if (req.method === "OPTIONS") {
+ return new Response("ok", { headers: CORS_HEADERS })
+ }
+
+ try {
+ const { content } = await req.json() as { content: string }
+
+ if (!content || content.trim().length < 10) {
+ return new Response(
+ JSON.stringify({ error: "Bài viết quá ngắn. Vui lòng nhập ít nhất 10 ký tự." }),
+ { status: 400, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
+ )
+ }
+
+ const stream = await dbiz.chat.completions.create({
+ model: "qwen-35b",
+ messages: [
+ { role: "system", content: SYSTEM_PROMPT },
+ { role: "user", content: `Analyse this writing:\n\n${content.slice(0, 2000)}` },
+ ],
+ temperature: 0.3,
+ max_tokens: 10000,
+ stream: true,
+ })
+
+ const encoder = new TextEncoder()
+ const body = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const chunk of stream) {
+ const text = chunk.choices[0]?.delta?.content ?? ""
+ if (text) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`))
+ }
+ }
+ } catch (err) {
+ console.error("writing-check-dbiz stream error:", err)
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ error: "Đã có lỗi khi chấm bài. Vui lòng thử lại." })}\n\n`),
+ )
+ }
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"))
+ controller.close()
+ },
+ })
+
+ return new Response(body, {
+ headers: {
+ ...CORS_HEADERS,
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ },
+ })
+ } catch (err) {
+ console.error("writing-check-dbiz error:", err)
+ return new Response(
+ JSON.stringify({ error: "Đã có lỗi khi chấm bài. Vui lòng thử lại." }),
+ { status: 500, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
+ )
+ }
+})
diff --git a/supabase/functions/writing-check/index.ts b/supabase/functions/writing-check/index.ts
index 3615593..733e2c8 100644
--- a/supabase/functions/writing-check/index.ts
+++ b/supabase/functions/writing-check/index.ts
@@ -1,6 +1,6 @@
// Supabase Edge Function: writing-check
// Uses GLM API (OpenAI-compatible) to analyze English writing submissions.
-// Deploy: supabase functions deploy writing-check
+// Deploy: supabase functions deploy writing-check --no-verify-jwt
// Secrets: supabase secrets set GLM_API_KEY=
import OpenAI from "npm:openai@^4"
@@ -13,10 +13,8 @@ const glm = new OpenAI({
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
- "Content-Type": "application/json",
}
-// Instructs the model to return a strict JSON structure with Vietnamese feedback.
const SYSTEM_PROMPT = `You are an expert English writing teacher specialising in TOEIC and IELTS assessment.
Analyse the student's writing and respond ONLY with valid JSON — no markdown, no extra text:
{
@@ -29,7 +27,6 @@ Analyse the student's writing and respond ONLY with valid JSON — no markdown,
}`
Deno.serve(async (req: Request) => {
- // Handle CORS pre-flight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: CORS_HEADERS })
}
@@ -40,13 +37,11 @@ Deno.serve(async (req: Request) => {
if (!content || content.trim().length < 10) {
return new Response(
JSON.stringify({ error: "Bài viết quá ngắn. Vui lòng nhập ít nhất 10 ký tự." }),
- { status: 400, headers: CORS_HEADERS },
+ { status: 400, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
)
}
- const completion = await glm.chat.completions.create({
- // GLM-4-32B-0414-128K: cheapest paid model at $0.1/$0.1 per 1M tokens.
- // Override via: supabase secrets set GLM_MODEL=
+ const stream = await glm.chat.completions.create({
model: Deno.env.get("GLM_MODEL") ?? "GLM-4.5-Flash",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
@@ -54,20 +49,42 @@ Deno.serve(async (req: Request) => {
],
temperature: 0.3,
max_tokens: 1500,
+ stream: true,
})
- const raw = completion.choices[0]?.message?.content ?? "{}"
+ const encoder = new TextEncoder()
+ const body = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const chunk of stream) {
+ const text = chunk.choices[0]?.delta?.content ?? ""
+ if (text) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`))
+ }
+ }
+ } catch (err) {
+ console.error("writing-check stream error:", err)
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ error: "Đã có lỗi khi chấm bài. Vui lòng thử lại." })}\n\n`),
+ )
+ }
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"))
+ controller.close()
+ },
+ })
- // Strip markdown code fences if the model adds them despite instructions
- const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim()
- const feedback = JSON.parse(cleaned)
-
- return new Response(JSON.stringify(feedback), { headers: CORS_HEADERS })
+ return new Response(body, {
+ headers: {
+ ...CORS_HEADERS,
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ },
+ })
} catch (err) {
console.error("writing-check error:", err)
return new Response(
JSON.stringify({ error: "Đã có lỗi khi chấm bài. Vui lòng thử lại." }),
- { status: 500, headers: CORS_HEADERS },
+ { status: 500, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
)
}
})