fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 tí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 tí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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user