This commit is contained in:
2026-04-13 15:17:59 +07:00
parent 77a0e38fa7
commit 01c5ccbd93
3 changed files with 143 additions and 34 deletions

View File

@@ -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=<your_key>
# GLM API — used by writing-check edge function (server-side only)
# Deploy with: supabase secrets set GLM_API_KEY=<your_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

View File

@@ -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 && (
<div className="bg-slate-900 rounded-2xl border border-slate-700 p-5 min-h-48 flex flex-col">
<div className="flex items-center gap-2 mb-3 flex-shrink-0">
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">AI đang phân ch...</span>
<div className="space-y-3">
{/* Score */}
<div className="bg-blue-600 rounded-2xl p-5 text-center">
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ưc nh</div>
{streamingScore ? (
<div className="text-5xl font-extrabold text-white mb-1">{streamingScore}</div>
) : (
<div className="h-12 w-20 mx-auto bg-blue-500/40 rounded-xl animate-pulse mb-1" />
)}
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
</div>
{streamingText ? (
<pre className="text-xs text-green-300 font-mono whitespace-pre-wrap leading-relaxed overflow-auto flex-1">
{streamingText}
<span className="animate-pulse"></span>
</pre>
) : (
<div className="flex items-center justify-center flex-1">
<div className="w-6 h-6 border-2 border-slate-700 border-t-blue-400 rounded-full animate-spin" />
{/* Grammar */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
</div>
)}
{streamingGrammar.length > 0 ? (
<ul className="space-y-1.5">
{streamingGrammar.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
{item}
</li>
))}
</ul>
) : (
<div className="space-y-2">
{[78, 92, 65].map((w, i) => (
<div key={i} className="flex items-start gap-2">
<div className="w-3.5 h-3.5 mt-0.5 rounded-full bg-slate-100 animate-pulse flex-shrink-0" />
<div className="h-3 bg-slate-100 rounded animate-pulse" style={{ width: `${w}%` }} />
</div>
))}
</div>
)}
</div>
{/* Vocabulary */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
</div>
{streamingVocab.length > 0 ? (
<ul className="space-y-1.5">
{streamingVocab.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
{item}
</li>
))}
</ul>
) : (
<div className="h-3 bg-slate-100 rounded animate-pulse w-4/5" />
)}
</div>
{/* Structure */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
</div>
{streamingStructure ? (
<p className="text-xs text-slate-600">{streamingStructure}</p>
) : (
<div className="space-y-1.5">
<div className="h-3 bg-slate-100 rounded animate-pulse" />
<div className="h-3 bg-slate-100 rounded animate-pulse w-5/6" />
<div className="h-3 bg-slate-100 rounded animate-pulse w-4/6" />
</div>
)}
</div>
{/* Summary */}
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
<div className="flex items-center gap-2 mb-2">
{streamingSummary ? (
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
) : (
<div className="w-4 h-4 bg-green-200 rounded animate-pulse" />
)}
{streamingSummary ? (
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
) : (
<div className="h-4 w-24 bg-green-200 rounded animate-pulse" />
)}
</div>
{streamingSummary ? (
<p className="text-xs text-slate-600">{streamingSummary}</p>
) : (
<div className="space-y-1.5">
<div className="h-3 bg-green-100 rounded animate-pulse" />
<div className="h-3 bg-green-100 rounded animate-pulse w-3/4" />
</div>
)}
</div>
</div>
)}

View File

@@ -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<WritingFeedback> {
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]