From 01c5ccbd93e5420e9f6a21dbdc851e68d6b5d2c3 Mon Sep 17 00:00:00 2001 From: renolation Date: Mon, 13 Apr 2026 15:17:59 +0700 Subject: [PATCH] fix --- .env.example | 9 +- .../writing/components/WritingChecker.tsx | 137 ++++++++++++++++-- src/hooks/use-writing-check.ts | 31 ++-- 3 files changed, 143 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 39c5a90..c444e7f 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,10 @@ VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... # Alternative key name (both are supported) # VITE_SUPABASE_ANON_KEY=eyJ... -# GLM API — https://open.bigmodel.cn/usercenter/apikeys -# Used by the writing-check Supabase Edge Function (server-side only, never expose in frontend) -# Deploy to Supabase with: supabase secrets set GLM_API_KEY= +# GLM API — used by writing-check edge function (server-side only) +# Deploy with: supabase secrets set GLM_API_KEY= GLM_API_KEY=your_glm_api_key_here + +# DBIZ API — https://ai-api.dbiz.com +# VITE_ prefix = exposed to browser (intentional, for direct streaming without edge function hop) +VITE_DBIZ_API_KEY=your_dbiz_api_key_here diff --git a/src/features/writing/components/WritingChecker.tsx b/src/features/writing/components/WritingChecker.tsx index 3abae4c..f3f12ff 100644 --- a/src/features/writing/components/WritingChecker.tsx +++ b/src/features/writing/components/WritingChecker.tsx @@ -12,6 +12,32 @@ const MAX_CHARS = 1000 const GUEST_LIMIT = 3 const AUTH_LIMIT = 10 +// Extract a string field from partial JSON stream +function extractTextField(partial: string, field: string): string { + const m = partial.match(new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`)) + if (!m) return '' + return m[1].replace(/\\n/g, '\n').replace(/\\"/g, '"') +} + +// Extract score from partial JSON stream as soon as the field is complete +function extractScore(partial: string): string | null { + const m = partial.match(/"score"\s*:\s*"([^"]+)"/) + return m ? m[1] : null +} + +// Extract completed array items from a partial JSON array field +function extractArrayField(partial: string, field: string): string[] { + const m = partial.match(new RegExp(`"${field}"\\s*:\\s*\\[([^\\]]*)`)) + if (!m) return [] + const items: string[] = [] + const re = /"((?:[^"\\]|\\.)*)"/g + let match + while ((match = re.exec(m[1])) !== null) { + items.push(match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')) + } + return items +} + export function WritingChecker() { const [text, setText] = useState('') const [improvedExpanded, setImprovedExpanded] = useState(false) @@ -37,6 +63,11 @@ export function WritingChecker() { }) }, [user, resetMutation]) + const streamingScore = isPending ? extractScore(streamingText) : null + const streamingGrammar = isPending ? extractArrayField(streamingText, 'grammar') : [] + const streamingVocab = isPending ? extractArrayField(streamingText, 'vocabulary') : [] + const streamingStructure = isPending ? extractTextField(streamingText, 'structure') : '' + const streamingSummary = isPending ? extractTextField(streamingText, 'summary') : '' const charCount = text.length const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending @@ -150,21 +181,101 @@ export function WritingChecker() { )} {isPending && ( -
-
-
- AI đang phân tích... +
+ {/* Score */} +
+
Band Score ước tính
+ {streamingScore ? ( +
{streamingScore}
+ ) : ( +
+ )} +
Dựa trên tiêu chí IELTS/TOEIC Writing
- {streamingText ? ( -
-                  {streamingText}
-                  
-                
- ) : ( -
-
+ {/* Grammar */} +
+
+
+ Ngữ pháp
- )} + {streamingGrammar.length > 0 ? ( +
    + {streamingGrammar.map((item, i) => ( +
  • + error + {item} +
  • + ))} +
+ ) : ( +
+ {[78, 92, 65].map((w, i) => ( +
+
+
+
+ ))} +
+ )} +
+ {/* Vocabulary */} +
+
+
+ Từ vựng +
+ {streamingVocab.length > 0 ? ( +
    + {streamingVocab.map((item, i) => ( +
  • + lightbulb + {item} +
  • + ))} +
+ ) : ( +
+ )} +
+ {/* Structure */} +
+
+
+ Cấu trúc +
+ {streamingStructure ? ( +

{streamingStructure}

+ ) : ( +
+
+
+
+
+ )} +
+ {/* Summary */} +
+
+ {streamingSummary ? ( + summarize + ) : ( +
+ )} + {streamingSummary ? ( + Tổng nhận xét + ) : ( +
+ )} +
+ {streamingSummary ? ( +

{streamingSummary}

+ ) : ( +
+
+
+
+ )} +
)} diff --git a/src/hooks/use-writing-check.ts b/src/hooks/use-writing-check.ts index 474c593..ede0746 100644 --- a/src/hooks/use-writing-check.ts +++ b/src/hooks/use-writing-check.ts @@ -1,7 +1,6 @@ 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" import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service" import type { WritingFeedback } from "@/types" @@ -9,24 +8,20 @@ const AUTH_DAILY_LIMIT = 10 const GUEST_DAILY_LIMIT = 3 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 +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. +// 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 { - 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", + Authorization: `Bearer ${SUPABASE_ANON_KEY}`, apikey: SUPABASE_ANON_KEY, - Authorization: `Bearer ${session?.access_token ?? SUPABASE_ANON_KEY}`, }, body: JSON.stringify({ content }), }) @@ -54,22 +49,23 @@ async function callEdgeFunction( const payload = line.slice(6).trim() if (payload === "[DONE]") continue - let event: { text?: string; error?: string } + let chunk: { text?: string; error?: string } try { - event = JSON.parse(payload) + chunk = JSON.parse(payload) } catch { - continue // skip malformed SSE lines + continue } - if (event.error) throw new Error(event.error) - if (event.text) { - accumulated += event.text - onChunk?.(event.text) + if (chunk.error) throw new Error(chunk.error) + + const text = chunk.text ?? "" + if (text) { + accumulated += text + onChunk?.(text) } } } - // Extract the outermost JSON object from the accumulated stream const start = accumulated.indexOf("{") const end = accumulated.lastIndexOf("}") if (start === -1 || end === -1) { @@ -78,7 +74,6 @@ async function callEdgeFunction( 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]