빅뱅 8/8 마지막 성공 기준 달성. Supabase Edge Function(llm-proxy)을 통해 Anthropic Claude를 호출하는 PremiumLLMService 신규 구현. 사용자가 Settings에서 Local/Premium 백엔드를 선택하면 VoiceModeService가 자동 분기하고, Premium 실패 시 Local로 silent fallback + 상단 중앙 배너 알림. 실측: Claude Haiku refine 1.6~3.2초 (이전 qwen3 42.9초 → 13~27배 빠름). 주요 변경: - PremiumLLMService 신규 (싱글톤+EventEmitter, processText/chatStream, Supabase functions.invoke 기반, _ensureAuth 가드) - llm-prompts.ts: SYSTEM_PROMPTS를 Local/Premium 공유 모듈로 추출 (resolveSystemPrompt 헬퍼) - VoiceModeService: _getLLMProcessor → _runProcessorWithFallback 라우터 + premium-llm-fallback 이벤트 - CloudSyncService: getAccessToken(async), getAnonKey, invokeFunction(auth 헤더 자동 처리, 에러 body 파싱) - IPC: LLM.PREMIUM_* 채널 6개 + preload API + llm-handlers 이벤트 전달 (safeSendToRenderer 헬퍼) - AppConfig.llmBackend: 'local' | 'premium' (기본 'local') - Settings UI: Backend 드롭다운 + Premium 선택 시 Ollama UI 숨김 + 라이선스 모달 자동 오픈 - AppLayout: 상단 중앙 Snackbar fallback 배너 (8초, warning filled) - LicenseModal: 라이선스 키 입력 제거 → SaaS 구독 관리 UI 전환 (Free/Pro/Pro+ 업그레이드 버튼, Payple 준비 중 스텁) - 등급 비교 표: featureLabel i18n 번역 수정 서버 (Supabase Edge Functions): - quota.ts: 모델별 쿼터 구조 (llm_haiku/sonnet/opus × free/pro/pro_plus), 주간/일간 기간 분리, modelToQuotaKey 매핑, consumeQuota baseLimit 파라미터화 - llm-proxy: 모델별 쿼터 체크 + 소비 (checkQuota → consumeQuota 원자적), verify_jwt=false (2026 sb_publishable_ 키 호환) - config.toml: llm-proxy verify_jwt = false - migration 20260412000001: tier team→pro_plus 통일, subscriptions.overage_credits 컬럼, consume_quota RPC (원자적 base→overage fallback) Tier/쿼터: - free: Haiku 250/주간, Sonnet/Opus 불가 - pro ₩9,900: Haiku 1500/일, Sonnet 300/일, Opus 50/일 - pro_plus ₩29,900: Haiku 무제한, Sonnet 1500/일, Opus 300/일 - api-client SubscriptionTier: team→pro_plus, overage_credits 필드 추가
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
// server/supabase/functions/stt-proxy/index.ts
|
|
// Google Cloud Speech-to-Text 프록시.
|
|
// 요청: multipart/form-data (audio + sample_rate + language_code)
|
|
// 응답: { transcript, confidence, language_code, duration_seconds }
|
|
|
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
|
import { checkQuota, consumeQuota, createServiceRoleClient } from '../_shared/quota.ts'
|
|
|
|
interface SttResult {
|
|
transcript: string
|
|
confidence: number
|
|
language_code: string
|
|
duration_seconds: number
|
|
}
|
|
|
|
// @ts-expect-error — Deno 런타임 전역
|
|
Deno.serve(async (req: Request) => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) return preflight
|
|
|
|
if (req.method !== 'POST') {
|
|
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
|
status: 405,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
// 1) 인증
|
|
const user = await requireUser(req)
|
|
|
|
// 2) 쿼터 체크
|
|
const serviceClient = createServiceRoleClient()
|
|
const quota = await checkQuota(user.id, 'stt_transcribe', serviceClient)
|
|
if (!quota.allowed) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'quota_exceeded',
|
|
current: quota.current,
|
|
limit: quota.limit,
|
|
tier: quota.tier
|
|
}),
|
|
{
|
|
status: 429,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
// 3) 입력 파싱
|
|
const formData = await req.formData()
|
|
const audio = formData.get('audio')
|
|
const sampleRate = Number(formData.get('sample_rate') ?? 16000)
|
|
const languageCode = String(formData.get('language_code') ?? 'ko-KR')
|
|
|
|
if (!(audio instanceof Blob)) {
|
|
return new Response(JSON.stringify({ error: 'Missing audio field' }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
// 4) Google Cloud STT 호출 — placeholder
|
|
//
|
|
// 실제 구현 시:
|
|
// const audioBytes = new Uint8Array(await audio.arrayBuffer())
|
|
// const base64Audio = btoa(String.fromCharCode(...audioBytes))
|
|
// const gcpKey = Deno.env.get('GOOGLE_CLOUD_STT_KEY')!
|
|
// const response = await fetch(
|
|
// `https://speech.googleapis.com/v1/speech:recognize?key=${gcpKey}`,
|
|
// {
|
|
// method: 'POST',
|
|
// headers: { 'Content-Type': 'application/json' },
|
|
// body: JSON.stringify({
|
|
// config: {
|
|
// encoding: 'LINEAR16',
|
|
// sampleRateHertz: sampleRate,
|
|
// languageCode,
|
|
// enableAutomaticPunctuation: true
|
|
// },
|
|
// audio: { content: base64Audio }
|
|
// })
|
|
// }
|
|
// )
|
|
// const data = await response.json()
|
|
// const transcript = data.results?.[0]?.alternatives?.[0]?.transcript ?? ''
|
|
// const confidence = data.results?.[0]?.alternatives?.[0]?.confidence ?? 0
|
|
//
|
|
// 스캐폴딩 단계에서는 placeholder 응답.
|
|
|
|
const placeholder: SttResult = {
|
|
transcript: '[stt-proxy placeholder — Google Cloud STT not yet wired]',
|
|
confidence: 0,
|
|
language_code: languageCode,
|
|
duration_seconds: (audio.size / sampleRate / 2) // 16-bit mono 가정
|
|
}
|
|
|
|
// 5) 쿼터 소비
|
|
await consumeQuota(user.id, 'stt_transcribe', serviceClient, quota.limit)
|
|
|
|
return new Response(JSON.stringify(placeholder), {
|
|
status: 200,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (err) {
|
|
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
|
return authErrorResponse(err as AuthError, corsHeaders)
|
|
}
|
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
return new Response(JSON.stringify({ error: message }), {
|
|
status: 500,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|