fix
This commit is contained in:
@@ -7,7 +7,10 @@ VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
|
|||||||
# Alternative key name (both are supported)
|
# Alternative key name (both are supported)
|
||||||
# VITE_SUPABASE_ANON_KEY=eyJ...
|
# VITE_SUPABASE_ANON_KEY=eyJ...
|
||||||
|
|
||||||
# GLM API — https://open.bigmodel.cn/usercenter/apikeys
|
# GLM API — used by writing-check edge function (server-side only)
|
||||||
# Used by the writing-check Supabase Edge Function (server-side only, never expose in frontend)
|
# Deploy with: supabase secrets set GLM_API_KEY=<your_key>
|
||||||
# Deploy to Supabase with: supabase secrets set GLM_API_KEY=<your_key>
|
|
||||||
GLM_API_KEY=your_glm_api_key_here
|
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 GUEST_LIMIT = 3
|
||||||
const AUTH_LIMIT = 10
|
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() {
|
export function WritingChecker() {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [improvedExpanded, setImprovedExpanded] = useState(false)
|
const [improvedExpanded, setImprovedExpanded] = useState(false)
|
||||||
@@ -37,6 +63,11 @@ export function WritingChecker() {
|
|||||||
})
|
})
|
||||||
}, [user, resetMutation])
|
}, [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 charCount = text.length
|
||||||
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
|
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
|
||||||
|
|
||||||
@@ -150,21 +181,101 @@ export function WritingChecker() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<div className="bg-slate-900 rounded-2xl border border-slate-700 p-5 min-h-48 flex flex-col">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 mb-3 flex-shrink-0">
|
{/* Score */}
|
||||||
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
<div className="bg-blue-600 rounded-2xl p-5 text-center">
|
||||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">AI đang phân tích...</span>
|
<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>
|
</div>
|
||||||
{streamingText ? (
|
{/* Grammar */}
|
||||||
<pre className="text-xs text-green-300 font-mono whitespace-pre-wrap leading-relaxed overflow-auto flex-1">
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
{streamingText}
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="animate-pulse">▋</span>
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
</pre>
|
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
|
||||||
) : (
|
|
||||||
<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" />
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
||||||
import { useAuthStore } from "@/store/auth-store"
|
import { useAuthStore } from "@/store/auth-store"
|
||||||
import { supabase } from "@/lib/supabase"
|
|
||||||
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
||||||
import type { WritingFeedback } from "@/types"
|
import type { WritingFeedback } from "@/types"
|
||||||
|
|
||||||
@@ -9,24 +8,20 @@ const AUTH_DAILY_LIMIT = 10
|
|||||||
const GUEST_DAILY_LIMIT = 3
|
const GUEST_DAILY_LIMIT = 3
|
||||||
|
|
||||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string
|
||||||
const SUPABASE_ANON_KEY = (
|
const SUPABASE_ANON_KEY = (import.meta.env.VITE_SUPABASE_ANON_KEY || import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY) as string
|
||||||
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.
|
// Calls the writing-check-dbiz Supabase edge function.
|
||||||
// Accumulates the full response, then extracts and parses the JSON object.
|
// SSE format emitted by the function: data: {"text":"..."} | data: [DONE]
|
||||||
async function callEdgeFunction(
|
async function callEdgeFunction(
|
||||||
content: string,
|
content: string,
|
||||||
onChunk?: (text: string) => void,
|
onChunk?: (text: string) => void,
|
||||||
): Promise<WritingFeedback> {
|
): Promise<WritingFeedback> {
|
||||||
const { data: { session } } = await supabase.auth.getSession()
|
|
||||||
|
|
||||||
const res = await fetch(`${SUPABASE_URL}/functions/v1/writing-check-dbiz`, {
|
const res = await fetch(`${SUPABASE_URL}/functions/v1/writing-check-dbiz`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
|
||||||
apikey: SUPABASE_ANON_KEY,
|
apikey: SUPABASE_ANON_KEY,
|
||||||
Authorization: `Bearer ${session?.access_token ?? SUPABASE_ANON_KEY}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({ content }),
|
||||||
})
|
})
|
||||||
@@ -54,22 +49,23 @@ async function callEdgeFunction(
|
|||||||
const payload = line.slice(6).trim()
|
const payload = line.slice(6).trim()
|
||||||
if (payload === "[DONE]") continue
|
if (payload === "[DONE]") continue
|
||||||
|
|
||||||
let event: { text?: string; error?: string }
|
let chunk: { text?: string; error?: string }
|
||||||
try {
|
try {
|
||||||
event = JSON.parse(payload)
|
chunk = JSON.parse(payload)
|
||||||
} catch {
|
} catch {
|
||||||
continue // skip malformed SSE lines
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.error) throw new Error(event.error)
|
if (chunk.error) throw new Error(chunk.error)
|
||||||
if (event.text) {
|
|
||||||
accumulated += event.text
|
const text = chunk.text ?? ""
|
||||||
onChunk?.(event.text)
|
if (text) {
|
||||||
|
accumulated += text
|
||||||
|
onChunk?.(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the outermost JSON object from the accumulated stream
|
|
||||||
const start = accumulated.indexOf("{")
|
const start = accumulated.indexOf("{")
|
||||||
const end = accumulated.lastIndexOf("}")
|
const end = accumulated.lastIndexOf("}")
|
||||||
if (start === -1 || end === -1) {
|
if (start === -1 || end === -1) {
|
||||||
@@ -78,7 +74,6 @@ async function callEdgeFunction(
|
|||||||
|
|
||||||
const raw = JSON.parse(accumulated.slice(start, end + 1))
|
const raw = JSON.parse(accumulated.slice(start, end + 1))
|
||||||
|
|
||||||
// Normalize: model sometimes returns array fields as a plain string
|
|
||||||
const toArray = (v: unknown): string[] => {
|
const toArray = (v: unknown): string[] => {
|
||||||
if (Array.isArray(v)) return v
|
if (Array.isArray(v)) return v
|
||||||
if (typeof v === "string" && v.length > 0) return [v]
|
if (typeof v === "string" && v.length > 0) return [v]
|
||||||
|
|||||||
Reference in New Issue
Block a user