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 && ( +
+
+ Đang tải... +
+ )} + + {!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" } }, ) } })