diff --git a/apps/desktop/src/main/ipc/voice-conversation-handlers.ts b/apps/desktop/src/main/ipc/voice-conversation-handlers.ts index 9c92be3..123e1b0 100644 --- a/apps/desktop/src/main/ipc/voice-conversation-handlers.ts +++ b/apps/desktop/src/main/ipc/voice-conversation-handlers.ts @@ -5,8 +5,13 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors' import { getVoiceConversationService } from '../services/VoiceConversationService' +import { getCloudSyncService } from '../services/CloudSyncService' import { getLogger } from '../services/LoggerService' -import type { ConversationSendParams } from '@d3ro/core/types' +import type { + ConversationSendParams, + RealtimeTokenParams, + RealtimeTokenResult, +} from '@d3ro/core/types' const logger = getLogger('voice-conversation-handlers') @@ -84,6 +89,47 @@ export function registerVoiceConversationHandlers(): void { } }) + // OpenAI Realtime ephemeral token — 렌더러가 WebRTC 직결에 사용 + ipcMain.handle( + IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, + async (_event, params: RealtimeTokenParams) => { + try { + const { data, error } = await getCloudSyncService().invokeFunction( + 'realtime-token', + { ...params }, + ) + if (error) { + return ipcError(ErrorCode.ConversationRealtimeTokenFailed, error.message) + } + const raw = data as { + value?: string + expires_at?: number + model?: string + tier?: string + error?: string + message?: string + } + if (!raw.value) { + return ipcError( + ErrorCode.ConversationRealtimeTokenFailed, + raw.error ?? raw.message ?? 'No token in response', + ) + } + const result: RealtimeTokenResult = { + value: raw.value, + expiresAt: raw.expires_at ?? 0, + model: raw.model ?? 'unknown', + tier: (raw.tier as RealtimeTokenResult['tier']) ?? 'free', + } + return ipcSuccess(result) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + logger.error('Realtime token request failed:', msg) + return ipcError(ErrorCode.ConversationRealtimeTokenFailed, msg) + } + }, + ) + // finishListening — 렌더러에서 녹음 종료 버튼 클릭 시 ipcMain.handle('voiceConversation:finishListening', async () => { try { diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 68b0c0f..2195875 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -31,6 +31,8 @@ const CONFIG_DEFAULTS: AppConfig = { // Phase 3.2: 기본값은 'local' — 누구나 로그인 없이 로컬 Ollama로 쓸 수 있는 // 엔트리 전략. 사용자가 Settings에서 'premium'으로 전환 시 로그인 + 구독 필요. llmBackend: 'local' as const, + // 라이브 음성 대화 백엔드 — 'realtime'은 로그인+구독 필요 (OpenAI Realtime WebRTC) + conversationBackend: 'local' as const, defaultLLMAction: 'refine', dictationShortcut: { keyCode: 0xa5, // Right Alt diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 84739a0..348b0ba 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -106,6 +106,8 @@ import type { ConversationAssistantDelta, ConversationAssistantMessage, ConversationError, + RealtimeTokenParams, + RealtimeTokenResult, // Phase 13.2 RAGDocument, RAGQueryParams, @@ -615,6 +617,11 @@ const electronAPI = { invoke(IPC_CHANNELS.VOICE_CONVERSATION.CLEAR_HISTORY), cancelResponse: () => invoke(IPC_CHANNELS.VOICE_CONVERSATION.CANCEL_RESPONSE), + getRealtimeToken: (params: RealtimeTokenParams) => + invoke( + IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, + params + ), finishListening: () => invoke('voiceConversation:finishListening'), onStateChanged: (cb: (data: ConversationSessionInfo) => void): Unsubscribe => diff --git a/apps/desktop/src/renderer/components/SettingsModal.tsx b/apps/desktop/src/renderer/components/SettingsModal.tsx index b27fef1..9eff66f 100644 --- a/apps/desktop/src/renderer/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/components/SettingsModal.tsx @@ -722,6 +722,37 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac : t('settings.backend.localHint')} + + + + {t('settings.conversationBackend')} + + + + {t('settings.conversationBackend')} + + + + + {config.conversationBackend === 'realtime' + ? t('settings.convBackend.realtimeHint') + : t('settings.convBackend.localHint')} + + {config.llmBackend !== 'premium' && ( <> diff --git a/apps/desktop/src/renderer/hooks/useRealtimeConversation.ts b/apps/desktop/src/renderer/hooks/useRealtimeConversation.ts new file mode 100644 index 0000000..4c17483 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useRealtimeConversation.ts @@ -0,0 +1,306 @@ +// src/renderer/hooks/useRealtimeConversation.ts +// OpenAI Realtime API (gpt-realtime-2.1) 라이브 음성 대화 훅. +// Supabase realtime-token(IPC 경유)으로 ephemeral key를 받아 +// 렌더러가 OpenAI와 직접 WebRTC 연결한다 (마이크 캡처 + 오디오 재생 자동). +// +// 이벤트 매핑 (data channel 'oai-events'): +// response.created → thinking +// response.(output_)audio_transcript.delta → speaking + 스트리밍 텍스트 +// conversation.item.input_audio_transcription.completed → 유저 메시지 +// response.done → 어시스턴트 메시지 확정 + listening 복귀 + +import { useState, useRef, useCallback, useEffect } from 'react' +import type { ConversationState, ConversationMessage } from '@d3ro/core/types' + +const REALTIME_CALLS_URL = 'https://api.openai.com/v1/realtime/calls' +const DATA_CHANNEL_NAME = 'oai-events' + +export type RealtimeConnectionState = 'idle' | 'connecting' | 'live' | 'error' + +interface RealtimeServerEvent { + type: string + delta?: string + transcript?: string + response?: { id?: string } + item_id?: string + error?: { message?: string } +} + +export interface UseRealtimeConversationResult { + connectionState: RealtimeConnectionState + conversationState: ConversationState + messages: ConversationMessage[] + isActive: boolean + streamingText: string + streamingMsgId: string | null + error: string | null + /** 연결 시작. 실패 시 false (로컬 파이프라인 fallback 유도) */ + start: () => Promise + stop: () => void + sendText: (text: string) => void + cancelResponse: () => void + clearMessages: () => void +} + +let messageSeq = 0 +function nextMessageId(prefix: string): string { + messageSeq += 1 + return `rt-${prefix}-${Date.now()}-${messageSeq}` +} + +export function useRealtimeConversation(): UseRealtimeConversationResult { + const [connectionState, setConnectionState] = useState('idle') + const [conversationState, setConversationState] = useState('idle') + const [messages, setMessages] = useState([]) + const [streamingText, setStreamingText] = useState('') + const [streamingMsgId, setStreamingMsgId] = useState(null) + const [error, setError] = useState(null) + + const pcRef = useRef(null) + const dcRef = useRef(null) + const micStreamRef = useRef(null) + const audioElRef = useRef(null) + const assistantBufferRef = useRef('') + const assistantMsgIdRef = useRef(null) + + const cleanup = useCallback(() => { + dcRef.current?.close() + dcRef.current = null + pcRef.current?.close() + pcRef.current = null + micStreamRef.current?.getTracks().forEach((track) => track.stop()) + micStreamRef.current = null + if (audioElRef.current) { + audioElRef.current.srcObject = null + audioElRef.current.remove() + audioElRef.current = null + } + assistantBufferRef.current = '' + assistantMsgIdRef.current = null + }, []) + + // 언마운트 시 연결 정리 + useEffect(() => cleanup, [cleanup]) + + const flushAssistantMessage = useCallback(() => { + const content = assistantBufferRef.current.trim() + const msgId = assistantMsgIdRef.current + assistantBufferRef.current = '' + assistantMsgIdRef.current = null + setStreamingText('') + setStreamingMsgId(null) + if (content && msgId) { + setMessages((prev) => [ + ...prev, + { id: msgId, role: 'assistant', content, timestamp: Date.now() }, + ]) + } + }, []) + + const handleServerEvent = useCallback( + (event: RealtimeServerEvent) => { + switch (event.type) { + case 'response.created': { + assistantBufferRef.current = '' + assistantMsgIdRef.current = nextMessageId('assistant') + setConversationState('thinking') + break + } + + // GA/신규 이벤트명 모두 수용 + case 'response.output_audio_transcript.delta': + case 'response.audio_transcript.delta': + case 'response.output_text.delta': + case 'response.text.delta': { + if (event.delta) { + assistantBufferRef.current += event.delta + setStreamingMsgId(assistantMsgIdRef.current) + setStreamingText(assistantBufferRef.current) + setConversationState('speaking') + } + break + } + + case 'conversation.item.input_audio_transcription.completed': { + const transcript = (event.transcript ?? '').trim() + if (transcript) { + setMessages((prev) => [ + ...prev, + { + id: nextMessageId('user'), + role: 'user', + content: transcript, + timestamp: Date.now(), + }, + ]) + } + break + } + + case 'response.done': { + flushAssistantMessage() + setConversationState('listening') + break + } + + case 'error': { + setError(event.error?.message ?? 'Realtime error') + break + } + + default: + break + } + }, + [flushAssistantMessage], + ) + + const start = useCallback(async (): Promise => { + if (pcRef.current) return true + + setError(null) + setConnectionState('connecting') + + try { + // 1) ephemeral token (Supabase realtime-token → OpenAI client_secrets) + const tokenResult = await window.electronAPI.voiceConversation.getRealtimeToken({}) + if (!tokenResult.success) { + throw new Error(tokenResult.error?.message ?? 'token request failed') + } + const { value: ephemeralKey, model } = tokenResult.data + + // 2) 마이크 + peer connection + const micStream = await navigator.mediaDevices.getUserMedia({ audio: true }) + micStreamRef.current = micStream + + const pc = new RTCPeerConnection() + pcRef.current = pc + const micTrack = micStream.getAudioTracks()[0] + if (micTrack) pc.addTrack(micTrack, micStream) + + // 3) 원격 오디오 재생 + const audioEl = document.createElement('audio') + audioEl.autoplay = true + audioElRef.current = audioEl + pc.ontrack = (e) => { + audioEl.srcObject = e.streams[0] ?? null + } + + pc.onconnectionstatechange = () => { + const cs = pc.connectionState + if (cs === 'failed' || cs === 'disconnected' || cs === 'closed') { + setConnectionState((prev) => (prev === 'live' ? 'error' : prev)) + } + } + + // 4) 이벤트 데이터 채널 + const dc = pc.createDataChannel(DATA_CHANNEL_NAME) + dcRef.current = dc + dc.addEventListener('message', (e: MessageEvent) => { + try { + handleServerEvent(JSON.parse(e.data as string) as RealtimeServerEvent) + } catch { + // 파싱 불가 이벤트 무시 + } + }) + dc.addEventListener('open', () => { + // 유저 발화 전사 활성화 — 미지원 모델이면 error 이벤트만 오고 대화는 유지됨 + dc.send( + JSON.stringify({ + type: 'session.update', + session: { + type: 'realtime', + audio: { + input: { transcription: { model: 'whisper-1' } }, + }, + }, + }), + ) + }) + + // 5) SDP 교환 + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + + const sdpResponse = await fetch(`${REALTIME_CALLS_URL}?model=${encodeURIComponent(model)}`, { + method: 'POST', + body: offer.sdp, + headers: { + Authorization: `Bearer ${ephemeralKey}`, + 'Content-Type': 'application/sdp', + }, + }) + if (!sdpResponse.ok) { + const text = await sdpResponse.text() + throw new Error(`SDP exchange failed (${sdpResponse.status}): ${text.slice(0, 200)}`) + } + + const answerSdp = await sdpResponse.text() + await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }) + + setConnectionState('live') + setConversationState('listening') + return true + } catch (err) { + cleanup() + setConnectionState('error') + setConversationState('idle') + setError(err instanceof Error ? err.message : String(err)) + return false + } + }, [cleanup, handleServerEvent]) + + const stop = useCallback(() => { + flushAssistantMessage() + cleanup() + setConnectionState('idle') + setConversationState('idle') + }, [cleanup, flushAssistantMessage]) + + const sendText = useCallback((text: string) => { + const dc = dcRef.current + if (!dc || dc.readyState !== 'open') return + setMessages((prev) => [ + ...prev, + { id: nextMessageId('user'), role: 'user', content: text, timestamp: Date.now() }, + ]) + dc.send( + JSON.stringify({ + type: 'conversation.item.create', + item: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text }], + }, + }), + ) + dc.send(JSON.stringify({ type: 'response.create' })) + }, []) + + const cancelResponse = useCallback(() => { + const dc = dcRef.current + if (!dc || dc.readyState !== 'open') return + dc.send(JSON.stringify({ type: 'response.cancel' })) + flushAssistantMessage() + setConversationState('listening') + }, [flushAssistantMessage]) + + const clearMessages = useCallback(() => { + setMessages([]) + }, []) + + return { + connectionState, + conversationState, + messages, + isActive: connectionState === 'connecting' || connectionState === 'live', + streamingText, + streamingMsgId, + error, + start, + stop, + sendText, + cancelResponse, + clearMessages, + } +} diff --git a/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx b/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx index b85fca1..7eb4ded 100644 --- a/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx +++ b/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx @@ -12,6 +12,7 @@ import CancelIcon from '@mui/icons-material/Cancel' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds' import { PageHeader } from '../components/shared' import { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel' +import { useRealtimeConversation } from '../hooks/useRealtimeConversation' import { isImeComposingEvent } from '../utils/keyboard' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' @@ -24,41 +25,68 @@ import type { export function VoiceConversationPage(): React.ReactElement { const { t } = useI18n() - const [state, setState] = useState('idle') - const [messages, setMessages] = useState([]) - const [isActive, setIsActive] = useState(false) - const [streamingText, setStreamingText] = useState('') - const [streamingMsgId, setStreamingMsgId] = useState(null) + const [localState, setLocalState] = useState('idle') + const [localMessages, setLocalMessages] = useState([]) + const [localActive, setLocalActive] = useState(false) + const [localStreamingText, setLocalStreamingText] = useState('') + const [localStreamingMsgId, setLocalStreamingMsgId] = useState(null) const [textInput, setTextInput] = useState('') const [errorBanner, setErrorBanner] = useState(null) + const [backend, setBackend] = useState<'local' | 'realtime'>('local') const messagesEndRef = useRef(null) + const realtime = useRealtimeConversation() + const isRealtime = backend === 'realtime' + + // 표시용 상태 — 백엔드에 따라 로컬 파이프라인(IPC) vs Realtime 훅 + const state = isRealtime ? realtime.conversationState : localState + const messages = isRealtime ? realtime.messages : localMessages + const isActive = isRealtime ? realtime.isActive : localActive + const streamingText = isRealtime ? realtime.streamingText : localStreamingText + const streamingMsgId = isRealtime ? realtime.streamingMsgId : localStreamingMsgId + + // 설정에서 대화 백엔드 로드 + useEffect(() => { + window.electronAPI.config.getAll().then((r) => { + if (r.success && r.data.conversationBackend === 'realtime') { + setBackend('realtime') + } + }) + }, []) + + // Realtime 에러 → 배너 + useEffect(() => { + if (realtime.error) { + setErrorBanner({ phase: 'llm', message: realtime.error }) + } + }, [realtime.error]) + const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, []) - // IPC 이벤트 구독 + // IPC 이벤트 구독 (로컬 파이프라인) useEffect(() => { const unsubState = window.electronAPI.voiceConversation.onStateChanged((info) => { - setState(info.state) - setMessages(info.messages) - setIsActive(info.isActive) + setLocalState(info.state) + setLocalMessages(info.messages) + setLocalActive(info.isActive) }) const unsubUser = window.electronAPI.voiceConversation.onUserMessage((msg) => { - setMessages((prev) => [...prev, msg]) - setStreamingText('') - setStreamingMsgId(null) + setLocalMessages((prev) => [...prev, msg]) + setLocalStreamingText('') + setLocalStreamingMsgId(null) setTimeout(scrollToBottom, 50) }) const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta((data: ConversationAssistantDelta) => { - setStreamingMsgId(data.messageId) - setStreamingText(data.accumulated) + setLocalStreamingMsgId(data.messageId) + setLocalStreamingText(data.accumulated) setTimeout(scrollToBottom, 50) }) const unsubComplete = window.electronAPI.voiceConversation.onAssistantMessage((msg) => { - setMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }]) - setStreamingText('') - setStreamingMsgId(null) + setLocalMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }]) + setLocalStreamingText('') + setLocalStreamingMsgId(null) setTimeout(scrollToBottom, 50) }) const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => { @@ -69,9 +97,9 @@ export function VoiceConversationPage(): React.ReactElement { // 초기 상태 로드 window.electronAPI.voiceConversation.getState().then((r) => { if (r.success) { - setState(r.data.state) - setMessages(r.data.messages) - setIsActive(r.data.isActive) + setLocalState(r.data.state) + setLocalMessages(r.data.messages) + setLocalActive(r.data.isActive) } }) @@ -84,36 +112,78 @@ export function VoiceConversationPage(): React.ReactElement { } }, [scrollToBottom]) + // Realtime 메시지 갱신 시 스크롤 + useEffect(() => { + if (isRealtime) setTimeout(scrollToBottom, 50) + }, [isRealtime, realtime.messages, realtime.streamingText, scrollToBottom]) + const handleStartSession = useCallback(async () => { + if (isRealtime) { + const ok = await realtime.start() + if (!ok) { + // Realtime 연결 실패 → 로컬 파이프라인 자동 fallback + setBackend('local') + setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') }) + await window.electronAPI.voiceConversation.startSession() + } + return + } await window.electronAPI.voiceConversation.startSession() - }, []) + }, [isRealtime, realtime, t]) const handleStopSession = useCallback(async () => { + if (isRealtime) { + realtime.stop() + return + } await window.electronAPI.voiceConversation.stopSession() - }, []) + }, [isRealtime, realtime]) const handleFinishListening = useCallback(async () => { + if (isRealtime) return // server VAD가 자동 처리 await window.electronAPI.voiceConversation.finishListening() - }, []) + }, [isRealtime]) const handleSendText = useCallback(async () => { if (!textInput.trim()) return const text = textInput.trim() setTextInput('') + if (isRealtime) { + if (!realtime.isActive) { + const ok = await realtime.start() + if (!ok) { + setBackend('local') + setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') }) + await window.electronAPI.voiceConversation.startSession() + await window.electronAPI.voiceConversation.sendMessage({ text }) + return + } + } + realtime.sendText(text) + return + } if (!isActive) { await window.electronAPI.voiceConversation.startSession() } await window.electronAPI.voiceConversation.sendMessage({ text }) - }, [textInput, isActive]) + }, [textInput, isActive, isRealtime, realtime, t]) const handleClearHistory = useCallback(async () => { + if (isRealtime) { + realtime.clearMessages() + return + } await window.electronAPI.voiceConversation.clearHistory() - setMessages([]) - }, []) + setLocalMessages([]) + }, [isRealtime, realtime]) const handleCancelResponse = useCallback(async () => { + if (isRealtime) { + realtime.cancelResponse() + return + } await window.electronAPI.voiceConversation.cancelResponse() - }, []) + }, [isRealtime, realtime]) const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => { if (isImeComposingEvent(e)) return @@ -130,6 +200,11 @@ export function VoiceConversationPage(): React.ReactElement { speaking: t('conversation.speaking'), } + const displayStateLabel = + isRealtime && realtime.connectionState === 'connecting' + ? t('conversation.realtime.connecting') + : stateLabel[state] + const formatErrorMessage = useCallback((err: ConversationError): string => { // Bug 13의 전형적 STT 메시지는 i18n 키로 매핑 — 그 외는 서비스 원문 + phase label. if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) { @@ -152,9 +227,23 @@ export function VoiceConversationPage(): React.ReactElement { title={t('conversation.title').toUpperCase()} action={ + {isRealtime && ( + + {t('conversation.realtime.badge')} + + )} - {stateLabel[state].toUpperCase()} + {displayStateLabel.toUpperCase()} {messages.length > 0 && ( @@ -167,8 +256,8 @@ export function VoiceConversationPage(): React.ReactElement { } /> - {/* 메시지 영역 — state 분기: listening=몰입 패널, 그 외=메시지 리스트 */} - {state === 'listening' ? ( + {/* 메시지 영역 — 로컬 파이프라인의 listening만 몰입 패널 (Realtime은 항상 채팅 뷰) */} + {!isRealtime && state === 'listening' ? ( ) : state === 'listening' ? ( - + ) : state === 'thinking' || state === 'speaking' ? ( @@ -305,8 +398,8 @@ export function VoiceConversationPage(): React.ReactElement { )} - {/* 텍스트 입력 — listening 시 숨김, thinking/speaking 시 disabled */} - {state !== 'listening' && ( + {/* 텍스트 입력 — 로컬 listening 시 숨김 (Realtime은 항상 표시), thinking/speaking 시 disabled */} + {(isRealtime || state !== 'listening') && ( setTextInput(e.target.value)} @@ -335,8 +428,8 @@ export function VoiceConversationPage(): React.ReactElement { /> )} - {/* 전송 버튼 — listening 시 숨김 */} - {state !== 'listening' && ( + {/* 전송 버튼 — 로컬 listening 시 숨김 */} + {(isRealtime || state !== 'listening') && ( > = { llm_haiku: { limit: 250, period: 'weekly' }, llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가 llm_opus: { limit: 0, period: 'daily' }, // 사용불가 + realtime_session: { limit: 0, period: 'daily' }, // 사용불가 }, pro: { stt_transcribe: { limit: -1, period: 'daily' }, llm_haiku: { limit: 1500, period: 'daily' }, llm_sonnet: { limit: 300, period: 'daily' }, llm_opus: { limit: 50, period: 'daily' }, + // 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정) + realtime_session: { limit: 30, period: 'daily' }, }, pro_plus: { stt_transcribe: { limit: -1, period: 'daily' }, llm_haiku: { limit: -1, period: 'daily' }, // 무제한 llm_sonnet: { limit: 1500, period: 'daily' }, llm_opus: { limit: 300, period: 'daily' }, + realtime_session: { limit: 120, period: 'daily' }, }, } diff --git a/server/supabase/functions/realtime-token/index.ts b/server/supabase/functions/realtime-token/index.ts new file mode 100644 index 0000000..8210acf --- /dev/null +++ b/server/supabase/functions/realtime-token/index.ts @@ -0,0 +1,173 @@ +// server/supabase/functions/realtime-token/index.ts +// OpenAI Realtime API ephemeral token 발급. +// 렌더러가 이 토큰으로 OpenAI와 직접 WebRTC 연결한다 (서버 키 비노출). +// 요청: application/json { model?, voice?, instructions? } +// 응답: OpenAI client_secrets 응답 그대로 + { model, tier } + +import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' +import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { + checkQuota, + consumeQuota, + createServiceRoleClient, + getQuotaPolicy, + type Tier, +} from '../_shared/quota.ts' + +interface RealtimeTokenRequest { + model?: string + voice?: string + instructions?: string +} + +/** 티어별 허용 Realtime 모델 — free 차단, pro는 mini만, pro_plus는 풀 모델까지 */ +const TIER_MODELS: Record = { + free: [], + pro: ['gpt-realtime-2.1-mini'], + pro_plus: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'], +} + +const DEFAULT_MODEL: Record = { + free: null, + pro: 'gpt-realtime-2.1-mini', + pro_plus: 'gpt-realtime-2.1', +} + +const DEFAULT_VOICE = 'marin' +const MAX_INSTRUCTIONS_LENGTH = 2000 + +// @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 { + const user = await requireUser(req) + const serviceClient = createServiceRoleClient() + + const body = (await req.json().catch(() => ({}))) as RealtimeTokenRequest + + // 1단계: 티어 + 세션 쿼터 확인 + const quota = await checkQuota(user.id, 'realtime_session', serviceClient) + const tier = quota.tier + + const requestedModel = body.model ?? DEFAULT_MODEL[tier] + if (!requestedModel || !TIER_MODELS[tier].includes(requestedModel)) { + return new Response( + JSON.stringify({ + error: tier === 'free' ? 'tier_not_allowed' : 'model_not_allowed', + tier, + requested: body.model ?? null, + allowed: TIER_MODELS[tier], + }), + { + status: 403, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ) + } + + if (!quota.allowed) { + return new Response( + JSON.stringify({ + error: 'quota_exceeded', + current: quota.current, + limit: quota.limit, + period: quota.period, + tier, + overage_credits: quota.overageCredits, + }), + { + status: 429, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ) + } + + // 2단계: 쿼터 소비 (세션 시작 = 1회) + const policy = getQuotaPolicy(tier, 'realtime_session') + const consume = await consumeQuota(user.id, 'realtime_session', serviceClient, policy.limit) + if (!consume.allowed) { + return new Response( + JSON.stringify({ + error: 'quota_exceeded', + current: consume.current, + limit: consume.limit, + tier, + overage_credits: consume.overageCredits, + }), + { + status: 429, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ) + } + + // @ts-expect-error — Deno.env + const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? '' + if (!openaiKey) { + return new Response( + JSON.stringify({ error: 'not_configured', message: 'OPENAI_API_KEY 미설정' }), + { + status: 503, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ) + } + + // 3단계: OpenAI ephemeral client secret 발급 + const instructions = + typeof body.instructions === 'string' + ? body.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH) + : undefined + + const openaiResp = await fetch('https://api.openai.com/v1/realtime/client_secrets', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${openaiKey}`, + 'OpenAI-Safety-Identifier': user.id, + }, + body: JSON.stringify({ + session: { + type: 'realtime', + model: requestedModel, + ...(instructions ? { instructions } : {}), + audio: { + output: { voice: body.voice ?? DEFAULT_VOICE }, + }, + }, + }), + }) + + if (!openaiResp.ok) { + const errText = await openaiResp.text() + throw new Error(`OpenAI ${openaiResp.status}: ${errText.slice(0, 500)}`) + } + + const data = await openaiResp.json() + return new Response( + JSON.stringify({ ...data, model: requestedModel, tier }), + { + 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' }, + }) + } +})